homegraph 1.1.2 → 1.2.0
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/LICENSE +21 -21
- package/README.md +268 -243
- package/dist/arkts/ohos-api-index.d.ts +15 -0
- package/dist/arkts/ohos-api-index.d.ts.map +1 -0
- package/dist/arkts/ohos-api-index.js +190 -0
- package/dist/arkts/ohos-api-index.js.map +1 -0
- package/dist/arkts/ohos-sdk-input.d.ts +36 -0
- package/dist/arkts/ohos-sdk-input.d.ts.map +1 -0
- package/dist/arkts/ohos-sdk-input.js +214 -0
- package/dist/arkts/ohos-sdk-input.js.map +1 -0
- package/dist/bin/command-supervision.d.ts +12 -0
- package/dist/bin/command-supervision.d.ts.map +1 -0
- package/dist/bin/command-supervision.js +86 -0
- package/dist/bin/command-supervision.js.map +1 -0
- package/dist/bin/homegraph.d.ts +1 -1
- package/dist/bin/homegraph.js +971 -71
- package/dist/bin/homegraph.js.map +1 -1
- package/dist/db/index.d.ts +20 -0
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +39 -0
- package/dist/db/index.js.map +1 -1
- package/dist/db/migrations.d.ts +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +58 -19
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/queries.d.ts +14 -0
- package/dist/db/queries.d.ts.map +1 -1
- package/dist/db/queries.js +314 -143
- package/dist/db/queries.js.map +1 -1
- package/dist/db/schema.sql +172 -152
- package/dist/directory.d.ts +32 -0
- package/dist/directory.d.ts.map +1 -1
- package/dist/directory.js +88 -5
- package/dist/directory.js.map +1 -1
- package/dist/extraction/arkts-batch-worker.d.ts +2 -0
- package/dist/extraction/arkts-batch-worker.d.ts.map +1 -0
- package/dist/extraction/arkts-batch-worker.js +28 -0
- package/dist/extraction/arkts-batch-worker.js.map +1 -0
- package/dist/extraction/context.d.ts +11 -0
- package/dist/extraction/context.d.ts.map +1 -1
- package/dist/extraction/context.js +20 -0
- package/dist/extraction/context.js.map +1 -1
- package/dist/extraction/index.d.ts +16 -2
- package/dist/extraction/index.d.ts.map +1 -1
- package/dist/extraction/index.js +619 -380
- package/dist/extraction/index.js.map +1 -1
- package/dist/extraction/languages/arkts-state-decorators.d.ts +13 -0
- package/dist/extraction/languages/arkts-state-decorators.d.ts.map +1 -0
- package/dist/extraction/languages/arkts-state-decorators.js +26 -0
- package/dist/extraction/languages/arkts-state-decorators.js.map +1 -0
- package/dist/extraction/languages/arkts-viewtree.d.ts +4 -2
- package/dist/extraction/languages/arkts-viewtree.d.ts.map +1 -1
- package/dist/extraction/languages/arkts-viewtree.js +21 -6
- package/dist/extraction/languages/arkts-viewtree.js.map +1 -1
- package/dist/extraction/languages/arkts.d.ts +98 -2
- package/dist/extraction/languages/arkts.d.ts.map +1 -1
- package/dist/extraction/languages/arkts.js +1290 -61
- package/dist/extraction/languages/arkts.js.map +1 -1
- package/dist/extraction/languages/c-cpp.d.ts +56 -0
- package/dist/extraction/languages/c-cpp.d.ts.map +1 -1
- package/dist/extraction/languages/c-cpp.js +198 -1
- package/dist/extraction/languages/c-cpp.js.map +1 -1
- package/dist/extraction/languages/ohos-api-consumer.d.ts +34 -0
- package/dist/extraction/languages/ohos-api-consumer.d.ts.map +1 -0
- package/dist/extraction/languages/ohos-api-consumer.js +283 -0
- package/dist/extraction/languages/ohos-api-consumer.js.map +1 -0
- package/dist/extraction/parse-pool.d.ts +126 -0
- package/dist/extraction/parse-pool.d.ts.map +1 -0
- package/dist/extraction/parse-pool.js +319 -0
- package/dist/extraction/parse-pool.js.map +1 -0
- package/dist/extraction/tree-sitter-types.d.ts +17 -0
- package/dist/extraction/tree-sitter-types.d.ts.map +1 -1
- package/dist/extraction/tree-sitter.d.ts +21 -0
- package/dist/extraction/tree-sitter.d.ts.map +1 -1
- package/dist/extraction/tree-sitter.js +198 -27
- package/dist/extraction/tree-sitter.js.map +1 -1
- package/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
- package/dist/graph/traversal.d.ts.map +1 -1
- package/dist/graph/traversal.js +76 -17
- package/dist/graph/traversal.js.map +1 -1
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +78 -4
- package/dist/index.js.map +1 -1
- package/dist/installer/instructions-template.js +9 -9
- package/dist/installer/targets/shared.d.ts +5 -6
- package/dist/installer/targets/shared.d.ts.map +1 -1
- package/dist/installer/targets/shared.js +5 -6
- package/dist/installer/targets/shared.js.map +1 -1
- package/dist/mcp/daemon-paths.d.ts +30 -3
- package/dist/mcp/daemon-paths.d.ts.map +1 -1
- package/dist/mcp/daemon-paths.js +50 -10
- package/dist/mcp/daemon-paths.js.map +1 -1
- package/dist/mcp/daemon-registry.d.ts.map +1 -1
- package/dist/mcp/daemon-registry.js +7 -3
- package/dist/mcp/daemon-registry.js.map +1 -1
- package/dist/mcp/daemon.d.ts +48 -0
- package/dist/mcp/daemon.d.ts.map +1 -1
- package/dist/mcp/daemon.js +196 -32
- package/dist/mcp/daemon.js.map +1 -1
- package/dist/mcp/engine.d.ts +17 -0
- package/dist/mcp/engine.d.ts.map +1 -1
- package/dist/mcp/engine.js +73 -1
- package/dist/mcp/engine.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +25 -43
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/liveness-watchdog.js +16 -16
- package/dist/mcp/ppid-watchdog.d.ts +18 -0
- package/dist/mcp/ppid-watchdog.d.ts.map +1 -1
- package/dist/mcp/ppid-watchdog.js +37 -0
- package/dist/mcp/ppid-watchdog.js.map +1 -1
- package/dist/mcp/query-cache.d.ts +25 -0
- package/dist/mcp/query-cache.d.ts.map +1 -0
- package/dist/mcp/query-cache.js +191 -0
- package/dist/mcp/query-cache.js.map +1 -0
- package/dist/mcp/query-pool.d.ts +94 -0
- package/dist/mcp/query-pool.d.ts.map +1 -0
- package/dist/mcp/query-pool.js +297 -0
- package/dist/mcp/query-pool.js.map +1 -0
- package/dist/mcp/query-worker.d.ts +24 -0
- package/dist/mcp/query-worker.d.ts.map +1 -0
- package/dist/mcp/query-worker.js +87 -0
- package/dist/mcp/query-worker.js.map +1 -0
- package/dist/mcp/server-instructions.d.ts +5 -7
- package/dist/mcp/server-instructions.d.ts.map +1 -1
- package/dist/mcp/server-instructions.js +72 -74
- package/dist/mcp/server-instructions.js.map +1 -1
- package/dist/mcp/tools.d.ts +94 -4
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +877 -71
- package/dist/mcp/tools.js.map +1 -1
- package/dist/project-config.d.ts +20 -0
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +42 -2
- package/dist/project-config.js.map +1 -1
- package/dist/reasoning/login.js +1 -1
- package/dist/reasoning/login.js.map +1 -1
- package/dist/reasoning/reasoner.js +32 -32
- package/dist/resolution/c-fnptr-synthesizer.d.ts +0 -28
- package/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -1
- package/dist/resolution/c-fnptr-synthesizer.js +765 -79
- package/dist/resolution/c-fnptr-synthesizer.js.map +1 -1
- package/dist/resolution/callback-synthesizer.d.ts +1 -1
- package/dist/resolution/callback-synthesizer.d.ts.map +1 -1
- package/dist/resolution/callback-synthesizer.js +72 -11
- package/dist/resolution/callback-synthesizer.js.map +1 -1
- package/dist/resolution/cooperative-yield.d.ts +32 -0
- package/dist/resolution/cooperative-yield.d.ts.map +1 -0
- package/dist/resolution/cooperative-yield.js +42 -0
- package/dist/resolution/cooperative-yield.js.map +1 -0
- package/dist/resolution/index.d.ts +11 -2
- package/dist/resolution/index.d.ts.map +1 -1
- package/dist/resolution/index.js +72 -4
- package/dist/resolution/index.js.map +1 -1
- package/dist/resolution/name-matcher.d.ts +22 -0
- package/dist/resolution/name-matcher.d.ts.map +1 -1
- package/dist/resolution/name-matcher.js +317 -20
- package/dist/resolution/name-matcher.js.map +1 -1
- package/dist/spec/config.d.ts +39 -0
- package/dist/spec/config.d.ts.map +1 -0
- package/dist/spec/config.js +304 -0
- package/dist/spec/config.js.map +1 -0
- package/dist/spec/db/commit-node.d.ts +23 -0
- package/dist/spec/db/commit-node.d.ts.map +1 -0
- package/dist/spec/db/commit-node.js +62 -0
- package/dist/spec/db/commit-node.js.map +1 -0
- package/dist/spec/db/fragment-node.d.ts +24 -0
- package/dist/spec/db/fragment-node.d.ts.map +1 -0
- package/dist/spec/db/fragment-node.js +128 -0
- package/dist/spec/db/fragment-node.js.map +1 -0
- package/dist/spec/db/fts.d.ts +74 -0
- package/dist/spec/db/fts.d.ts.map +1 -0
- package/dist/spec/db/fts.js +324 -0
- package/dist/spec/db/fts.js.map +1 -0
- package/dist/spec/db/index.d.ts +13 -0
- package/dist/spec/db/index.d.ts.map +1 -0
- package/dist/spec/db/index.js +50 -0
- package/dist/spec/db/index.js.map +1 -0
- package/dist/spec/db/relations.d.ts +55 -0
- package/dist/spec/db/relations.d.ts.map +1 -0
- package/dist/spec/db/relations.js +158 -0
- package/dist/spec/db/relations.js.map +1 -0
- package/dist/spec/db/schema.d.ts +33 -0
- package/dist/spec/db/schema.d.ts.map +1 -0
- package/dist/spec/db/schema.js +119 -0
- package/dist/spec/db/schema.js.map +1 -0
- package/dist/spec/db/schema.sql +117 -0
- package/dist/spec/db/spec-node.d.ts +41 -0
- package/dist/spec/db/spec-node.d.ts.map +1 -0
- package/dist/spec/db/spec-node.js +114 -0
- package/dist/spec/db/spec-node.js.map +1 -0
- package/dist/spec/evolve/impact-locator.d.ts +13 -0
- package/dist/spec/evolve/impact-locator.d.ts.map +1 -0
- package/dist/spec/evolve/impact-locator.js +25 -0
- package/dist/spec/evolve/impact-locator.js.map +1 -0
- package/dist/spec/evolve/llm-client.d.ts +50 -0
- package/dist/spec/evolve/llm-client.d.ts.map +1 -0
- package/dist/spec/evolve/llm-client.js +176 -0
- package/dist/spec/evolve/llm-client.js.map +1 -0
- package/dist/spec/evolve/logic-checker.d.ts +12 -0
- package/dist/spec/evolve/logic-checker.d.ts.map +1 -0
- package/dist/spec/evolve/logic-checker.js +24 -0
- package/dist/spec/evolve/logic-checker.js.map +1 -0
- package/dist/spec/evolve/pipeline.d.ts +42 -0
- package/dist/spec/evolve/pipeline.d.ts.map +1 -0
- package/dist/spec/evolve/pipeline.js +567 -0
- package/dist/spec/evolve/pipeline.js.map +1 -0
- package/dist/spec/evolve/spec-rewriter.d.ts +42 -0
- package/dist/spec/evolve/spec-rewriter.d.ts.map +1 -0
- package/dist/spec/evolve/spec-rewriter.js +230 -0
- package/dist/spec/evolve/spec-rewriter.js.map +1 -0
- package/dist/spec/graph/queries.d.ts +155 -0
- package/dist/spec/graph/queries.d.ts.map +1 -0
- package/dist/spec/graph/queries.js +440 -0
- package/dist/spec/graph/queries.js.map +1 -0
- package/dist/spec/llm/client.d.ts +29 -0
- package/dist/spec/llm/client.d.ts.map +1 -0
- package/dist/spec/llm/client.js +123 -0
- package/dist/spec/llm/client.js.map +1 -0
- package/dist/spec/llm/index.d.ts +3 -0
- package/dist/spec/llm/index.d.ts.map +1 -0
- package/dist/spec/llm/index.js +11 -0
- package/dist/spec/llm/index.js.map +1 -0
- package/dist/spec/llm/prompts.d.ts +13 -0
- package/dist/spec/llm/prompts.d.ts.map +1 -0
- package/dist/spec/llm/prompts.js +75 -0
- package/dist/spec/llm/prompts.js.map +1 -0
- package/dist/spec/mining/diff-parser.d.ts +33 -0
- package/dist/spec/mining/diff-parser.d.ts.map +1 -0
- package/dist/spec/mining/diff-parser.js +166 -0
- package/dist/spec/mining/diff-parser.js.map +1 -0
- package/dist/spec/mining/git-scanner.d.ts +103 -0
- package/dist/spec/mining/git-scanner.d.ts.map +1 -0
- package/dist/spec/mining/git-scanner.js +307 -0
- package/dist/spec/mining/git-scanner.js.map +1 -0
- package/dist/spec/mining/pipeline.d.ts +53 -0
- package/dist/spec/mining/pipeline.d.ts.map +1 -0
- package/dist/spec/mining/pipeline.js +178 -0
- package/dist/spec/mining/pipeline.js.map +1 -0
- package/dist/spec/mining/scope-resolver.d.ts +45 -0
- package/dist/spec/mining/scope-resolver.d.ts.map +1 -0
- package/dist/spec/mining/scope-resolver.js +103 -0
- package/dist/spec/mining/scope-resolver.js.map +1 -0
- package/dist/spec/mining/spec-extractor.d.ts +69 -0
- package/dist/spec/mining/spec-extractor.d.ts.map +1 -0
- package/dist/spec/mining/spec-extractor.js +369 -0
- package/dist/spec/mining/spec-extractor.js.map +1 -0
- package/dist/spec/types.d.ts +149 -0
- package/dist/spec/types.d.ts.map +1 -0
- package/dist/spec/types.js +15 -0
- package/dist/spec/types.js.map +1 -0
- package/dist/spec/utils.d.ts +167 -0
- package/dist/spec/utils.d.ts.map +1 -0
- package/dist/spec/utils.js +463 -0
- package/dist/spec/utils.js.map +1 -0
- package/dist/sync/worktree.d.ts +9 -0
- package/dist/sync/worktree.d.ts.map +1 -1
- package/dist/sync/worktree.js +40 -0
- package/dist/sync/worktree.js.map +1 -1
- package/dist/types.d.ts +6 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/shimmer-progress.d.ts +2 -0
- package/dist/ui/shimmer-progress.d.ts.map +1 -1
- package/dist/ui/shimmer-progress.js +19 -2
- package/dist/ui/shimmer-progress.js.map +1 -1
- package/dist/upgrade/index.js +1 -1
- package/dist/upgrade/index.js.map +1 -1
- package/package.json +58 -57
- package/scripts/add-lang/bench.sh +60 -60
- package/scripts/add-lang/check-grammar.mjs +75 -75
- package/scripts/add-lang/dump-ast.mjs +103 -103
- package/scripts/add-lang/verify-extraction.mjs +70 -70
- package/scripts/agent-eval/ab-adoption.sh +91 -91
- package/scripts/agent-eval/ab-hook.sh +86 -86
- package/scripts/agent-eval/ab-impl.sh +78 -78
- package/scripts/agent-eval/ab-new-vs-baseline.sh +102 -102
- package/scripts/agent-eval/ab-sufficiency.sh +78 -78
- package/scripts/agent-eval/arms-F.sh +21 -21
- package/scripts/agent-eval/arms-matrix.sh +37 -37
- package/scripts/agent-eval/audit.sh +68 -68
- package/scripts/agent-eval/bench-readme.sh +28 -28
- package/scripts/agent-eval/bench-why-repo.sh +22 -22
- package/scripts/agent-eval/block-read-hook.sh +19 -19
- package/scripts/agent-eval/hook-settings.json +15 -15
- package/scripts/agent-eval/itrun.sh +120 -120
- package/scripts/agent-eval/offload-eval-3arm.sh +72 -72
- package/scripts/agent-eval/offload-eval-cost.mjs +133 -133
- package/scripts/agent-eval/offload-eval-effort.mjs +108 -108
- package/scripts/agent-eval/offload-eval-frontload-matrix.sh +25 -25
- package/scripts/agent-eval/offload-eval-frontload.sh +47 -47
- package/scripts/agent-eval/offload-eval-ground-truth.json +18 -18
- package/scripts/agent-eval/offload-eval-hook.mjs +84 -84
- package/scripts/agent-eval/offload-eval-judge.mjs +103 -103
- package/scripts/agent-eval/offload-eval-matrix.sh +20 -20
- package/scripts/agent-eval/offload-eval-metrics.mjs +94 -94
- package/scripts/agent-eval/offload-eval-refs1.sh +50 -50
- package/scripts/agent-eval/offload-eval-setup.sh +24 -24
- package/scripts/agent-eval/offload-eval-styles.sh +71 -71
- package/scripts/agent-eval/offload-eval-summarize.mjs +68 -68
- package/scripts/agent-eval/offload-eval.md +76 -76
- package/scripts/agent-eval/parse-arms.mjs +116 -116
- package/scripts/agent-eval/parse-bench-readme.mjs +84 -84
- package/scripts/agent-eval/parse-run.mjs +45 -45
- package/scripts/agent-eval/parse-session.mjs +93 -93
- package/scripts/agent-eval/probe-context.mjs +21 -21
- package/scripts/agent-eval/probe-explore.mjs +40 -40
- package/scripts/agent-eval/probe-node.mjs +20 -20
- package/scripts/agent-eval/probe-sweep.mjs +119 -119
- package/scripts/agent-eval/probe-trace.mjs +20 -20
- package/scripts/agent-eval/redirect-read-hook.sh +38 -38
- package/scripts/agent-eval/repro-concurrent-explore.mjs +119 -0
- package/scripts/agent-eval/repro-daemon-clients.mjs +125 -0
- package/scripts/agent-eval/run-agent.sh +34 -34
- package/scripts/agent-eval/run-all.sh +69 -69
- package/scripts/agent-eval/run-arms.sh +56 -56
- package/scripts/agent-eval/seq-matrix.mjs +137 -137
- package/scripts/build-bundle.sh +118 -118
- package/scripts/exp_boundary_eval/README.md +247 -0
- package/scripts/exp_boundary_eval/__pycache__/_utils.cpython-310.pyc +0 -0
- package/scripts/exp_boundary_eval/__pycache__/analyze.cpython-310.pyc +0 -0
- package/scripts/exp_boundary_eval/__pycache__/deveco_arm.cpython-310.pyc +0 -0
- package/scripts/exp_boundary_eval/__pycache__/run_one.cpython-310.pyc +0 -0
- package/scripts/exp_boundary_eval/__pycache__/run_session.cpython-310.pyc +0 -0
- package/scripts/exp_boundary_eval/__pycache__/setup.cpython-310.pyc +0 -0
- package/scripts/exp_boundary_eval/_test_mcp_chain.py +78 -0
- package/scripts/exp_boundary_eval/_test_stdin.py +8 -0
- package/scripts/exp_boundary_eval/_utils.py +1116 -0
- package/scripts/exp_boundary_eval/analyze.py +1313 -0
- package/scripts/exp_boundary_eval/data/agents.json +109 -0
- package/scripts/exp_boundary_eval/data/experiments.json +140 -0
- package/scripts/exp_boundary_eval/deveco_arm.py +519 -0
- package/scripts/exp_boundary_eval/run_all.py +378 -0
- package/scripts/exp_boundary_eval/run_one.py +165 -0
- package/scripts/exp_boundary_eval/run_session.py +158 -0
- package/scripts/exp_boundary_eval/setup.py +120 -0
- package/scripts/exp_boundary_eval/win_mcp_launcher.py +73 -0
- package/scripts/exp_boundary_eval/win_mcp_stdio_wrap.js +36 -0
- package/scripts/exp_boundary_eval/win_node_launcher.py +24 -0
- package/scripts/extract-release-notes.mjs +130 -130
- package/scripts/local-install.sh +41 -41
- package/scripts/npm-sdk.js +75 -75
- package/scripts/npm-shim.js +268 -246
- package/scripts/ohos-sdk-publish.mjs +133 -0
- package/scripts/pack-npm.sh +119 -119
- package/scripts/prepare-release.mjs +270 -270
- package/scripts/qa_eval/README.md +407 -404
- package/scripts/qa_eval/_test_deveco_probe.py +41 -41
- package/scripts/qa_eval/agent_runner.py +526 -526
- package/scripts/qa_eval/data/.gitignore +4 -4
- package/scripts/qa_eval/data/test-set.jsonl +2 -22
- package/scripts/qa_eval/eval_metrics.py +274 -233
- package/scripts/qa_eval/external_agent.py +976 -671
- package/scripts/qa_eval/llm_config.py +92 -92
- package/scripts/qa_eval/memory_monitor.py +132 -132
- package/scripts/qa_eval/my_answer_accuracy.py +187 -187
- package/scripts/qa_eval/requirements.txt +2 -2
- package/scripts/qa_eval/run_pipeline.py +804 -711
- package/scripts/qa_eval/stats_efficiency.py +279 -279
- package/scripts/qa_eval/stats_scores.py +207 -207
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared utilities for experiment scripts — multi-agent support."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Tuple
|
|
16
|
+
|
|
17
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
18
|
+
EVAL_ROOT = SCRIPT_DIR
|
|
19
|
+
DATA_DIR = EVAL_ROOT / "data"
|
|
20
|
+
LOG_DIR = EVAL_ROOT / "log"
|
|
21
|
+
OUTPUT_DIR = EVAL_ROOT / "output"
|
|
22
|
+
CONFIG_FILE = DATA_DIR / "experiments.json"
|
|
23
|
+
AGENTS_FILE = DATA_DIR / "agents.json"
|
|
24
|
+
# Legacy aliases used by analyze.py
|
|
25
|
+
RESULTS_DIR = OUTPUT_DIR
|
|
26
|
+
STATE_DIR = LOG_DIR
|
|
27
|
+
|
|
28
|
+
_HOMEGRAPH_TOOL_RE = re.compile(
|
|
29
|
+
r"homegraph(?:_homegraph)?_(?:explore|node|search|callers|callees)", re.I,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Windows: stop PowerShell/node subprocesses from flashing console windows.
|
|
33
|
+
_CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def subprocess_no_window_kwargs() -> dict:
|
|
37
|
+
"""Extra kwargs for subprocess.run/Popen on Windows (no-op elsewhere)."""
|
|
38
|
+
if sys.platform == "win32":
|
|
39
|
+
si = subprocess.STARTUPINFO()
|
|
40
|
+
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
41
|
+
si.wShowWindow = 0
|
|
42
|
+
return {"creationflags": _CREATE_NO_WINDOW, "startupinfo": si}
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def merge_subprocess_kwargs(kwargs: dict) -> dict:
|
|
47
|
+
"""Merge caller kwargs with subprocess_no_window_kwargs (OR creationflags)."""
|
|
48
|
+
merged = dict(kwargs)
|
|
49
|
+
extra = subprocess_no_window_kwargs()
|
|
50
|
+
if "creationflags" in extra:
|
|
51
|
+
merged["creationflags"] = merged.get("creationflags", 0) | extra["creationflags"]
|
|
52
|
+
if sys.platform == "win32" and "startupinfo" not in merged:
|
|
53
|
+
si = subprocess.STARTUPINFO()
|
|
54
|
+
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
55
|
+
si.wShowWindow = 0
|
|
56
|
+
merged["startupinfo"] = si
|
|
57
|
+
return merged
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_homegraph_tool(name: str) -> bool:
|
|
61
|
+
n = str(name or "").strip()
|
|
62
|
+
if not n:
|
|
63
|
+
return False
|
|
64
|
+
if _HOMEGRAPH_TOOL_RE.search(n):
|
|
65
|
+
return True
|
|
66
|
+
return n.lower() in ("homegraph_explore", "homegraph_node")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def count_homegraph_tools(tool_names: list) -> int:
|
|
70
|
+
return sum(1 for t in tool_names if is_homegraph_tool(t))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def create_output_dirs(agent_name: str = "", arm: str = "baseline") -> Tuple[Path, Path]:
|
|
74
|
+
"""Create timestamped run directories.
|
|
75
|
+
|
|
76
|
+
Input/output (prompt, stream, results) → output/{arm}/{agent}/{ts}/
|
|
77
|
+
Logs/state/clone/session backups → log/{arm}/{agent}/{ts}/
|
|
78
|
+
"""
|
|
79
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
80
|
+
agent = agent_name or "default"
|
|
81
|
+
run_key = Path(arm) / agent / ts
|
|
82
|
+
results_dir = OUTPUT_DIR / run_key
|
|
83
|
+
state_dir = LOG_DIR / run_key
|
|
84
|
+
results_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
return results_dir, state_dir
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def append_run_log(state_dir: Path, message: str):
|
|
90
|
+
"""Append a line to the per-run log (everything except raw agent I/O)."""
|
|
91
|
+
log_path = state_dir / "run.log"
|
|
92
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
94
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
95
|
+
f.write(f"[{ts}] {message}\n")
|
|
96
|
+
|
|
97
|
+
RED = "\033[0;31m"
|
|
98
|
+
GREEN = "\033[0;32m"
|
|
99
|
+
CYAN = "\033[0;36m"
|
|
100
|
+
YELLOW = "\033[1;33m"
|
|
101
|
+
NC = "\033[0m"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def log(msg: str): print(f"{GREEN}[SETUP]{NC} {msg}")
|
|
105
|
+
def info(msg: str): print(f"{GREEN}[RUN]{NC} {msg}")
|
|
106
|
+
def warn(msg: str): print(f"{YELLOW}[WARN]{NC} {msg}")
|
|
107
|
+
def error(msg: str): print(f"{RED}[ERROR]{NC} {msg}", file=sys.stderr)
|
|
108
|
+
def header(msg: str): print(f"\n{CYAN}==== {msg} ===={NC}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_config() -> dict:
|
|
112
|
+
return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def find_experiment(exp_id: str) -> Optional[dict]:
|
|
116
|
+
for exp in load_config()["experiments"]:
|
|
117
|
+
if exp["id"] == exp_id:
|
|
118
|
+
return exp
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def generate_session_id() -> str:
|
|
123
|
+
return str(uuid.uuid4())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def current_time_ms() -> int:
|
|
127
|
+
return int(time.time() * 1000)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_cmd_ok(cmd: list, cwd: Optional[Path] = None) -> bool:
|
|
131
|
+
r = subprocess.run(
|
|
132
|
+
cmd, capture_output=True, text=True, cwd=str(cwd) if cwd else None,
|
|
133
|
+
**subprocess_no_window_kwargs(),
|
|
134
|
+
)
|
|
135
|
+
return r.returncode == 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def git_output(cmd: list, cwd: Path) -> str:
|
|
139
|
+
r = subprocess.run(
|
|
140
|
+
["git"] + cmd, capture_output=True, text=True, cwd=cwd,
|
|
141
|
+
**subprocess_no_window_kwargs(),
|
|
142
|
+
)
|
|
143
|
+
return r.stdout.strip()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def git_ok(cmd: list, cwd: Path) -> bool:
|
|
147
|
+
return run_cmd_ok(["git"] + cmd, cwd=cwd)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def shell_ok(cmd: str, cwd: Optional[Path] = None) -> bool:
|
|
151
|
+
r = subprocess.run(
|
|
152
|
+
cmd, shell=True, capture_output=True, text=True,
|
|
153
|
+
cwd=str(cwd) if cwd else None,
|
|
154
|
+
**subprocess_no_window_kwargs(),
|
|
155
|
+
)
|
|
156
|
+
return r.returncode == 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def count_mentions(text: str, pattern: str) -> int:
|
|
160
|
+
return len(re.findall(pattern, text, re.IGNORECASE))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def file_mentions(filepath: Path, pattern: str) -> bool:
|
|
164
|
+
if not filepath.exists():
|
|
165
|
+
return False
|
|
166
|
+
return count_mentions(filepath.read_text(errors="ignore"), pattern) > 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def is_portrait(width: int, height: int) -> bool:
|
|
170
|
+
"""判断图片是否为竖屏(高度大于宽度)。"""
|
|
171
|
+
return height > width
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def write_json(path: Path, data: dict):
|
|
175
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ═══════════════════════════════════════════════════════════
|
|
180
|
+
# Process memory monitoring (agent + HomeGraph sidecar)
|
|
181
|
+
# ═══════════════════════════════════════════════════════════
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
import psutil # type: ignore
|
|
185
|
+
_HAS_PSUTIL = True
|
|
186
|
+
except ImportError:
|
|
187
|
+
psutil = None # type: ignore
|
|
188
|
+
_HAS_PSUTIL = False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass
|
|
192
|
+
class MemoryStats:
|
|
193
|
+
peak_rss_mb: float = 0.0
|
|
194
|
+
peak_homegraph_rss_mb: float = 0.0
|
|
195
|
+
peak_combined_rss_mb: float = 0.0
|
|
196
|
+
samples: int = 0
|
|
197
|
+
|
|
198
|
+
def to_dict(self) -> dict:
|
|
199
|
+
return {
|
|
200
|
+
"peak_rss_mb": round(self.peak_rss_mb, 1),
|
|
201
|
+
"peak_homegraph_rss_mb": round(self.peak_homegraph_rss_mb, 1),
|
|
202
|
+
"peak_combined_rss_mb": round(self.peak_combined_rss_mb, 1),
|
|
203
|
+
"samples": self.samples,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _bytes_to_mb(n: float) -> float:
|
|
208
|
+
return n / (1024 * 1024)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _tree_rss_psutil(root_pid: int) -> float:
|
|
212
|
+
try:
|
|
213
|
+
proc = psutil.Process(root_pid)
|
|
214
|
+
total = proc.memory_info().rss
|
|
215
|
+
for child in proc.children(recursive=True):
|
|
216
|
+
try:
|
|
217
|
+
total += child.memory_info().rss
|
|
218
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
219
|
+
pass
|
|
220
|
+
return float(total)
|
|
221
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
222
|
+
return 0.0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _homegraph_rss_psutil() -> float:
|
|
226
|
+
total = 0.0
|
|
227
|
+
for proc in psutil.process_iter(["cmdline"]):
|
|
228
|
+
try:
|
|
229
|
+
cmdline = " ".join(proc.info.get("cmdline") or [])
|
|
230
|
+
if "homegraph" in cmdline.lower():
|
|
231
|
+
total += proc.memory_info().rss
|
|
232
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
233
|
+
pass
|
|
234
|
+
return total
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _tree_rss_powershell(root_pid: int) -> float:
|
|
238
|
+
"""Windows fallback: filtered CIM queries (no full process scan)."""
|
|
239
|
+
if sys.platform != "win32" or root_pid <= 0:
|
|
240
|
+
return 0.0
|
|
241
|
+
script = (
|
|
242
|
+
f"$root={root_pid};$seen=@{{}};$q=[Collections.Queue]::new();"
|
|
243
|
+
"$q.Enqueue($root);$t=0L;"
|
|
244
|
+
"while($q.Count -gt 0){"
|
|
245
|
+
"$p=$q.Dequeue();if($seen[$p]){continue};$seen[$p]=$true;"
|
|
246
|
+
"$proc=Get-CimInstance Win32_Process -Filter \"ProcessId=$p\" -ErrorAction SilentlyContinue;"
|
|
247
|
+
"if($proc){$t+=[int64]$proc.WorkingSetSize;"
|
|
248
|
+
"Get-CimInstance Win32_Process -Filter \"ParentProcessId=$p\" -ErrorAction SilentlyContinue | "
|
|
249
|
+
"ForEach-Object {$q.Enqueue($_.ProcessId)}}};$t"
|
|
250
|
+
)
|
|
251
|
+
r = subprocess.run(
|
|
252
|
+
["powershell", "-NoProfile", "-Command", script],
|
|
253
|
+
capture_output=True, text=True, timeout=5,
|
|
254
|
+
**subprocess_no_window_kwargs(),
|
|
255
|
+
)
|
|
256
|
+
if r.returncode != 0:
|
|
257
|
+
return 0.0
|
|
258
|
+
try:
|
|
259
|
+
return float(r.stdout.strip())
|
|
260
|
+
except ValueError:
|
|
261
|
+
return 0.0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _homegraph_rss_powershell() -> float:
|
|
265
|
+
if sys.platform != "win32":
|
|
266
|
+
return 0.0
|
|
267
|
+
# node.exe 数量远少于全进程,比扫 Win32_Process 快
|
|
268
|
+
script = (
|
|
269
|
+
"$t=0L;Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" -ErrorAction SilentlyContinue | "
|
|
270
|
+
"Where-Object { $_.CommandLine -like '*homegraph*' } | "
|
|
271
|
+
"ForEach-Object { $t += [int64]$_.WorkingSetSize }; $t"
|
|
272
|
+
)
|
|
273
|
+
r = subprocess.run(
|
|
274
|
+
["powershell", "-NoProfile", "-Command", script],
|
|
275
|
+
capture_output=True, text=True, timeout=5,
|
|
276
|
+
**subprocess_no_window_kwargs(),
|
|
277
|
+
)
|
|
278
|
+
if r.returncode != 0:
|
|
279
|
+
return 0.0
|
|
280
|
+
out = r.stdout.strip()
|
|
281
|
+
if not out or out.lower() == "nan":
|
|
282
|
+
return 0.0
|
|
283
|
+
try:
|
|
284
|
+
return float(out)
|
|
285
|
+
except ValueError:
|
|
286
|
+
return 0.0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _sample_rss(root_pid: int, track_homegraph: bool) -> Tuple[float, float]:
|
|
290
|
+
if not _HAS_PSUTIL:
|
|
291
|
+
# Without psutil, do not spawn PowerShell every 0.5s (console flash on Windows).
|
|
292
|
+
return 0.0, 0.0
|
|
293
|
+
agent = _tree_rss_psutil(root_pid)
|
|
294
|
+
hg = _homegraph_rss_psutil() if track_homegraph else 0.0
|
|
295
|
+
return agent, hg
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def run_monitored_subprocess(cmd: list, poll_interval: float = 0.5,
|
|
299
|
+
track_homegraph: bool = False,
|
|
300
|
+
homegraph_poll_interval: float = 3.0,
|
|
301
|
+
**popen_kwargs):
|
|
302
|
+
"""Run subprocess and poll peak RSS of agent tree (+ optional HomeGraph sidecar)."""
|
|
303
|
+
stats = MemoryStats()
|
|
304
|
+
proc = subprocess.Popen(cmd, **merge_subprocess_kwargs(popen_kwargs))
|
|
305
|
+
last_hg_poll = 0.0
|
|
306
|
+
cached_hg_b = 0.0
|
|
307
|
+
|
|
308
|
+
def _record(agent_b: float, hg_b: float):
|
|
309
|
+
stats.samples += 1
|
|
310
|
+
stats.peak_rss_mb = max(stats.peak_rss_mb, _bytes_to_mb(agent_b))
|
|
311
|
+
stats.peak_homegraph_rss_mb = max(stats.peak_homegraph_rss_mb, _bytes_to_mb(hg_b))
|
|
312
|
+
stats.peak_combined_rss_mb = max(
|
|
313
|
+
stats.peak_combined_rss_mb,
|
|
314
|
+
_bytes_to_mb(agent_b + hg_b),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
while proc.poll() is None:
|
|
319
|
+
now = time.time()
|
|
320
|
+
agent_b, _ = _sample_rss(proc.pid, track_homegraph=False)
|
|
321
|
+
if track_homegraph and (now - last_hg_poll >= homegraph_poll_interval):
|
|
322
|
+
_, cached_hg_b = _sample_rss(proc.pid, track_homegraph=True)
|
|
323
|
+
last_hg_poll = now
|
|
324
|
+
_record(agent_b, cached_hg_b if track_homegraph else 0.0)
|
|
325
|
+
time.sleep(poll_interval)
|
|
326
|
+
agent_b, hg_b = _sample_rss(proc.pid, track_homegraph=False)
|
|
327
|
+
if track_homegraph:
|
|
328
|
+
_, hg_b = _sample_rss(proc.pid, track_homegraph=True)
|
|
329
|
+
else:
|
|
330
|
+
hg_b = 0.0
|
|
331
|
+
_record(agent_b, hg_b)
|
|
332
|
+
finally:
|
|
333
|
+
stdout = proc.stdout
|
|
334
|
+
stderr = proc.stderr
|
|
335
|
+
if stdout and hasattr(stdout, "close"):
|
|
336
|
+
stdout.close()
|
|
337
|
+
if stderr and hasattr(stderr, "close"):
|
|
338
|
+
stderr.close()
|
|
339
|
+
|
|
340
|
+
return subprocess.CompletedProcess(
|
|
341
|
+
cmd, proc.returncode, stdout=None, stderr=None,
|
|
342
|
+
), stats
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def read_run_manifest(results_dir: Path) -> dict:
|
|
346
|
+
"""Load run_manifest.json from results dir if present."""
|
|
347
|
+
path = results_dir / "run_manifest.json"
|
|
348
|
+
if path.exists():
|
|
349
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
350
|
+
return {}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def load_agents() -> dict:
|
|
355
|
+
"""Load agent definitions from config/agents.json."""
|
|
356
|
+
if AGENTS_FILE.exists():
|
|
357
|
+
return json.loads(AGENTS_FILE.read_text(encoding="utf-8"))
|
|
358
|
+
# Fallback: minimal built-in definition for Claude Code
|
|
359
|
+
return {
|
|
360
|
+
"default": "claude",
|
|
361
|
+
"agents": {
|
|
362
|
+
"claude": {
|
|
363
|
+
"name": "Claude Code", "binary": "claude",
|
|
364
|
+
"flags": {
|
|
365
|
+
"print": "-p", "verbose": "--verbose",
|
|
366
|
+
"output_format": ["--output-format", "stream-json"],
|
|
367
|
+
"session": ["--session-id"], "resume": ["--resume"],
|
|
368
|
+
"max_turns": ["--max-turns"],
|
|
369
|
+
"skip_permissions": ["--dangerously-skip-permissions"],
|
|
370
|
+
},
|
|
371
|
+
"parser": "claude_stream_json",
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def resolve_agent_binary(agent: dict) -> str:
|
|
378
|
+
"""Resolve CLI path: env override → absolute path → PATH lookup → configured name."""
|
|
379
|
+
env_key = agent.get("binary_env", "")
|
|
380
|
+
if env_key:
|
|
381
|
+
override = os.environ.get(env_key, "").strip()
|
|
382
|
+
if override:
|
|
383
|
+
return override
|
|
384
|
+
name = agent.get("binary", "")
|
|
385
|
+
if name and Path(name).is_file():
|
|
386
|
+
return str(Path(name).resolve())
|
|
387
|
+
found = shutil.which(name)
|
|
388
|
+
return found or name
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def verify_agent_binary(agent: dict) -> Tuple[bool, str, str]:
|
|
392
|
+
"""Return (ok, resolved_path, error_message)."""
|
|
393
|
+
binary = resolve_agent_binary(agent)
|
|
394
|
+
label = agent.get("name", agent.get("binary", "agent"))
|
|
395
|
+
configured = agent.get("binary", "")
|
|
396
|
+
|
|
397
|
+
if not binary:
|
|
398
|
+
return False, "", f"未配置 {label} 的 CLI 可执行文件。"
|
|
399
|
+
|
|
400
|
+
on_path = shutil.which(binary) is not None or Path(binary).is_file()
|
|
401
|
+
if not on_path:
|
|
402
|
+
env_hint = ""
|
|
403
|
+
if agent.get("binary_env"):
|
|
404
|
+
env_hint = f"\n 或设置环境变量 {agent['binary_env']}=/完整/路径/{configured}"
|
|
405
|
+
return False, binary, (
|
|
406
|
+
f"找不到 {label} 的 CLI:`{configured}` 不在 PATH 中。{env_hint}"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
r = subprocess.run([binary, "--version"], capture_output=True, text=True, **subprocess_no_window_kwargs())
|
|
410
|
+
if r.returncode != 0:
|
|
411
|
+
detail = (r.stdout + r.stderr).strip().replace("\n", " ")[:300]
|
|
412
|
+
extra = ""
|
|
413
|
+
if configured == "deveco":
|
|
414
|
+
extra = (
|
|
415
|
+
"\n DevEco Code 官方仅支持 Windows x64 和 macOS(无 Linux 包)。\n"
|
|
416
|
+
" WSL 请改用 Windows 侧跑评测(CMD 用 pushd,PowerShell 可直接 cd UNC):\n"
|
|
417
|
+
" pushd \\\\wsl.localhost\\Ubuntu-22.04\\home\\fujiaxin\\code\\homegraph\\scripts\\exp_boundary_eval\n"
|
|
418
|
+
" python run_all.py --both-arms --agent deveco"
|
|
419
|
+
)
|
|
420
|
+
return False, binary, (
|
|
421
|
+
f"{label} 已找到 `{binary}`,但 `{configured} --version` 失败:\n"
|
|
422
|
+
f" {detail}{extra}"
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return True, binary, ""
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def get_agent(agent_name: str = "") -> dict:
|
|
429
|
+
"""Get agent definition by name. Uses default if empty."""
|
|
430
|
+
data = load_agents()
|
|
431
|
+
name = agent_name or data.get("default", "claude")
|
|
432
|
+
agent = data.get("agents", {}).get(name)
|
|
433
|
+
if agent is None:
|
|
434
|
+
error(f"Agent '{name}' not found in agents.json. Available: {list(data.get('agents', {}).keys())}")
|
|
435
|
+
sys.exit(1)
|
|
436
|
+
agent = dict(agent)
|
|
437
|
+
agent["_binary"] = resolve_agent_binary(agent)
|
|
438
|
+
return agent
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def get_agent_version(agent: dict) -> str:
|
|
442
|
+
"""Try to get agent version string."""
|
|
443
|
+
binary = agent.get("_binary") or resolve_agent_binary(agent)
|
|
444
|
+
r = subprocess.run([binary, "--version"], capture_output=True, text=True, **subprocess_no_window_kwargs())
|
|
445
|
+
return (r.stdout + r.stderr).strip().split("\n")[0] or "unknown"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def build_agent_cmd(agent: dict, session_id: str, max_turns: int, prompt: str,
|
|
449
|
+
resume: bool = False, skip_permissions: bool = True,
|
|
450
|
+
repo_root: Optional[Path] = None) -> list:
|
|
451
|
+
"""Build CLI command for any registered agent.
|
|
452
|
+
|
|
453
|
+
Handles agents with missing flags gracefully:
|
|
454
|
+
- If 'session' flag is in 'missing', uses 'resume' or 'continue' instead
|
|
455
|
+
- If 'max_turns' is missing, skips it (no turn limit enforcement)
|
|
456
|
+
- If 'skip_permissions' is missing, warns and skips
|
|
457
|
+
"""
|
|
458
|
+
# Dedup warnings per agent per run
|
|
459
|
+
if not hasattr(build_agent_cmd, '_warned'):
|
|
460
|
+
build_agent_cmd._warned = set()
|
|
461
|
+
warned = build_agent_cmd._warned
|
|
462
|
+
agent_key = agent.get('binary', '')
|
|
463
|
+
|
|
464
|
+
flags = agent["flags"]
|
|
465
|
+
missing = set(agent.get("missing", []))
|
|
466
|
+
binary = agent.get("_binary") or resolve_agent_binary(agent)
|
|
467
|
+
|
|
468
|
+
cmd = [binary]
|
|
469
|
+
|
|
470
|
+
# Print mode (non-interactive) — can be flag (-p) or subcommand (run/exec)
|
|
471
|
+
print_flag = flags.get("print", "")
|
|
472
|
+
if print_flag:
|
|
473
|
+
cmd.append(print_flag)
|
|
474
|
+
|
|
475
|
+
# Verbose
|
|
476
|
+
if flags.get("verbose") and "verbose" not in missing:
|
|
477
|
+
cmd.append(flags["verbose"])
|
|
478
|
+
|
|
479
|
+
# Output format
|
|
480
|
+
for f in flags.get("output_format", []):
|
|
481
|
+
cmd.append(f)
|
|
482
|
+
|
|
483
|
+
# Session / resume
|
|
484
|
+
# "session" in missing only affects NEW sessions (some agents can resume
|
|
485
|
+
# existing sessions but can't create them via a flag, e.g. DevEco).
|
|
486
|
+
if resume and flags.get("resume"):
|
|
487
|
+
cmd.extend(flags["resume"])
|
|
488
|
+
cmd.append(session_id)
|
|
489
|
+
elif resume and flags.get("continue"):
|
|
490
|
+
# Agent uses --continue instead of --resume
|
|
491
|
+
cmd.extend(flags["continue"])
|
|
492
|
+
elif not resume and flags.get("session") and "session" not in missing:
|
|
493
|
+
cmd.extend(flags["session"])
|
|
494
|
+
cmd.append(session_id)
|
|
495
|
+
|
|
496
|
+
# Max turns — skip if agent doesn't support it
|
|
497
|
+
if flags.get("max_turns") and "max_turns" not in missing:
|
|
498
|
+
cmd.extend(flags["max_turns"])
|
|
499
|
+
cmd.append(str(max_turns))
|
|
500
|
+
elif "max_turns" in missing:
|
|
501
|
+
key = f'{agent_key}:max_turns'
|
|
502
|
+
if key not in warned:
|
|
503
|
+
info(f"Agent '{agent['name']}' has no max-turns flag; turn limits not enforced")
|
|
504
|
+
warned.add(key)
|
|
505
|
+
|
|
506
|
+
# Skip permissions — skip if agent doesn't support it
|
|
507
|
+
if skip_permissions and flags.get("skip_permissions") and "skip_permissions" not in missing:
|
|
508
|
+
cmd.extend(flags["skip_permissions"])
|
|
509
|
+
elif skip_permissions and "skip_permissions" in missing:
|
|
510
|
+
key = f'{agent_key}:skip_perms'
|
|
511
|
+
if key not in warned:
|
|
512
|
+
info(f"Agent '{agent['name']}' does not support skip-permissions; prompts may appear")
|
|
513
|
+
warned.add(key)
|
|
514
|
+
|
|
515
|
+
# DevEco reads project MCP from --dir (repo/.deveco/deveco.jsonc)
|
|
516
|
+
if repo_root and agent.get("binary") == "deveco":
|
|
517
|
+
cmd.extend(["--dir", str(repo_root)])
|
|
518
|
+
|
|
519
|
+
cmd.append(prompt)
|
|
520
|
+
return cmd
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ═══════════════════════════════════════════════════════════
|
|
524
|
+
# Output parsers (one per agent format)
|
|
525
|
+
# ═══════════════════════════════════════════════════════════
|
|
526
|
+
|
|
527
|
+
@dataclass
|
|
528
|
+
class StreamStats:
|
|
529
|
+
tool_calls: int = 0
|
|
530
|
+
homegraph_tool_calls: int = 0
|
|
531
|
+
used_homegraph: bool = False
|
|
532
|
+
tool_names: list = field(default_factory=list)
|
|
533
|
+
files_read: list = field(default_factory=list)
|
|
534
|
+
files_edited: list = field(default_factory=list)
|
|
535
|
+
files_grepped: list = field(default_factory=list)
|
|
536
|
+
assistant_messages: int = 0
|
|
537
|
+
text_lines: list = field(default_factory=list)
|
|
538
|
+
thinking_lines: list = field(default_factory=list)
|
|
539
|
+
total_input_tokens: int = 0
|
|
540
|
+
total_output_tokens: int = 0
|
|
541
|
+
max_turns_hit: bool = False
|
|
542
|
+
errors: list = field(default_factory=list)
|
|
543
|
+
model: str = ""
|
|
544
|
+
deveco_session_id: str = ""
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _is_deveco_session_id(value: str) -> bool:
|
|
548
|
+
return bool(value) and value.startswith("ses_")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _format_deveco_model(model_obj) -> str:
|
|
552
|
+
if isinstance(model_obj, str) and model_obj:
|
|
553
|
+
return model_obj
|
|
554
|
+
if isinstance(model_obj, dict):
|
|
555
|
+
model_id = model_obj.get("id") or model_obj.get("modelID", "")
|
|
556
|
+
provider_id = model_obj.get("providerID", "")
|
|
557
|
+
if model_id and provider_id:
|
|
558
|
+
return f"{provider_id}/{model_id}"
|
|
559
|
+
return model_id or provider_id or ""
|
|
560
|
+
return ""
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def resolve_deveco_model(deveco_session_id: str) -> str:
|
|
564
|
+
"""Resolve model name via `deveco export <sessionID>`."""
|
|
565
|
+
if not _is_deveco_session_id(deveco_session_id):
|
|
566
|
+
return ""
|
|
567
|
+
cache = getattr(resolve_deveco_model, "_cache", None)
|
|
568
|
+
if cache is None:
|
|
569
|
+
cache = {}
|
|
570
|
+
resolve_deveco_model._cache = cache
|
|
571
|
+
if deveco_session_id in cache:
|
|
572
|
+
return cache[deveco_session_id]
|
|
573
|
+
try:
|
|
574
|
+
result = subprocess.run(
|
|
575
|
+
["deveco", "export", deveco_session_id],
|
|
576
|
+
capture_output=True, text=True, timeout=15,
|
|
577
|
+
**subprocess_no_window_kwargs(),
|
|
578
|
+
)
|
|
579
|
+
if result.returncode != 0:
|
|
580
|
+
cache[deveco_session_id] = ""
|
|
581
|
+
return ""
|
|
582
|
+
raw = result.stdout.strip()
|
|
583
|
+
json_start = raw.find("{")
|
|
584
|
+
if json_start < 0:
|
|
585
|
+
cache[deveco_session_id] = ""
|
|
586
|
+
return ""
|
|
587
|
+
data = json.loads(raw[json_start:])
|
|
588
|
+
model = _format_deveco_model(data.get("info", {}).get("model", ""))
|
|
589
|
+
cache[deveco_session_id] = model
|
|
590
|
+
return model
|
|
591
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
|
|
592
|
+
cache[deveco_session_id] = ""
|
|
593
|
+
return ""
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def resolve_agent_model(agent: dict, stats: StreamStats) -> str:
|
|
597
|
+
"""Return the best available model label for an agent run."""
|
|
598
|
+
if agent.get("binary") == "deveco" or agent.get("parser") == "deveco_json":
|
|
599
|
+
deveco_sid = stats.deveco_session_id or (
|
|
600
|
+
stats.model if _is_deveco_session_id(stats.model) else ""
|
|
601
|
+
)
|
|
602
|
+
if deveco_sid:
|
|
603
|
+
resolved = resolve_deveco_model(deveco_sid)
|
|
604
|
+
if resolved:
|
|
605
|
+
return resolved
|
|
606
|
+
if stats.model and not _is_deveco_session_id(stats.model):
|
|
607
|
+
return stats.model
|
|
608
|
+
return ""
|
|
609
|
+
return stats.model
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _collect_paths_from_obj(obj, paths: list):
|
|
613
|
+
"""Recursively collect file paths from nested grep/glob result objects."""
|
|
614
|
+
if isinstance(obj, dict):
|
|
615
|
+
for key, val in obj.items():
|
|
616
|
+
if key in ("file", "path") and isinstance(val, str) and val and not val.startswith("http"):
|
|
617
|
+
if "/" in val or "." in val:
|
|
618
|
+
paths.append(val)
|
|
619
|
+
elif key == "files" and isinstance(val, list):
|
|
620
|
+
for item in val:
|
|
621
|
+
if isinstance(item, str):
|
|
622
|
+
paths.append(item)
|
|
623
|
+
else:
|
|
624
|
+
_collect_paths_from_obj(val, paths)
|
|
625
|
+
elif isinstance(obj, list):
|
|
626
|
+
for item in obj:
|
|
627
|
+
_collect_paths_from_obj(item, paths)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _parse_cursor_stream_json(filepath: Path) -> StreamStats:
|
|
631
|
+
"""Cursor Agent stream-json format (type=tool_call, camelCase tool names)."""
|
|
632
|
+
stats = StreamStats()
|
|
633
|
+
if not filepath.exists():
|
|
634
|
+
return stats
|
|
635
|
+
|
|
636
|
+
READ_TOOLS = {"readToolCall"}
|
|
637
|
+
WRITE_TOOLS = {"editToolCall", "writeToolCall", "searchReplaceToolCall"}
|
|
638
|
+
SEARCH_TOOLS = {"grepToolCall", "globToolCall", "semanticSearchToolCall", "listToolCall"}
|
|
639
|
+
|
|
640
|
+
with open(filepath, errors="ignore") as f:
|
|
641
|
+
for line in f:
|
|
642
|
+
line = line.strip()
|
|
643
|
+
if not line:
|
|
644
|
+
continue
|
|
645
|
+
try:
|
|
646
|
+
event = json.loads(line)
|
|
647
|
+
except json.JSONDecodeError:
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
ev_type = event.get("type", "")
|
|
651
|
+
if ev_type == "system" and event.get("subtype") == "init":
|
|
652
|
+
stats.model = event.get("model", stats.model)
|
|
653
|
+
|
|
654
|
+
if ev_type == "assistant":
|
|
655
|
+
stats.assistant_messages += 1
|
|
656
|
+
msg = event.get("message", {})
|
|
657
|
+
usage = msg.get("usage", {})
|
|
658
|
+
stats.total_input_tokens += usage.get("input_tokens", 0)
|
|
659
|
+
stats.total_output_tokens += usage.get("output_tokens", 0)
|
|
660
|
+
blocks = msg.get("content", [])
|
|
661
|
+
if isinstance(blocks, str):
|
|
662
|
+
blocks = [{"type": "text", "text": blocks}]
|
|
663
|
+
for block in blocks:
|
|
664
|
+
if block.get("type") == "text":
|
|
665
|
+
stats.text_lines.append(block.get("text", ""))
|
|
666
|
+
|
|
667
|
+
if ev_type == "tool_call" and event.get("subtype") == "completed":
|
|
668
|
+
stats.tool_calls += 1
|
|
669
|
+
tc = event.get("tool_call", {})
|
|
670
|
+
for tool_key, tool_data in tc.items():
|
|
671
|
+
if not tool_key.endswith("ToolCall"):
|
|
672
|
+
continue
|
|
673
|
+
stats.tool_names.append(tool_key.replace("ToolCall", ""))
|
|
674
|
+
args = tool_data.get("args", {}) if isinstance(tool_data, dict) else {}
|
|
675
|
+
result = tool_data.get("result", {}) if isinstance(tool_data, dict) else {}
|
|
676
|
+
path = args.get("path") or args.get("targetFile") or args.get("filePath") or ""
|
|
677
|
+
if tool_key in READ_TOOLS and path:
|
|
678
|
+
stats.files_read.append(path)
|
|
679
|
+
elif tool_key in WRITE_TOOLS and path:
|
|
680
|
+
stats.files_edited.append(path)
|
|
681
|
+
if tool_key in SEARCH_TOOLS:
|
|
682
|
+
found: list = []
|
|
683
|
+
_collect_paths_from_obj(result, found)
|
|
684
|
+
if not found and path:
|
|
685
|
+
found.append(path)
|
|
686
|
+
stats.files_grepped.extend(found)
|
|
687
|
+
|
|
688
|
+
if ev_type == "error":
|
|
689
|
+
stats.errors.append(str(event.get("message", event))[:200])
|
|
690
|
+
|
|
691
|
+
if ev_type == "result":
|
|
692
|
+
if event.get("is_error"):
|
|
693
|
+
stats.errors.append(str(event.get("result", ""))[:200])
|
|
694
|
+
|
|
695
|
+
return stats
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def detect_stream_parser(filepath: Path) -> str:
|
|
699
|
+
"""Auto-detect Claude vs Cursor stream-json format."""
|
|
700
|
+
if not filepath.exists():
|
|
701
|
+
return "claude_stream_json"
|
|
702
|
+
scanned = 0
|
|
703
|
+
with open(filepath, errors="ignore") as f:
|
|
704
|
+
for line in f:
|
|
705
|
+
line = line.strip()
|
|
706
|
+
if not line:
|
|
707
|
+
continue
|
|
708
|
+
try:
|
|
709
|
+
event = json.loads(line)
|
|
710
|
+
except json.JSONDecodeError:
|
|
711
|
+
continue
|
|
712
|
+
scanned += 1
|
|
713
|
+
if event.get("type") == "tool_call":
|
|
714
|
+
tc = event.get("tool_call", {})
|
|
715
|
+
if any(k.endswith("ToolCall") for k in tc):
|
|
716
|
+
return "cursor_stream_json"
|
|
717
|
+
if any(k == "tool_use" for k in tc):
|
|
718
|
+
return "claude_stream_json"
|
|
719
|
+
if event.get("type") == "assistant":
|
|
720
|
+
blocks = event.get("message", {}).get("content", [])
|
|
721
|
+
if isinstance(blocks, list) and any(b.get("type") == "tool_use" for b in blocks):
|
|
722
|
+
return "claude_stream_json"
|
|
723
|
+
if scanned >= 50:
|
|
724
|
+
break
|
|
725
|
+
return "claude_stream_json"
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def parse_output(filepath: Path, parser_name: str) -> StreamStats:
|
|
729
|
+
"""Dispatch to the appropriate parser based on agent type."""
|
|
730
|
+
parsers = {
|
|
731
|
+
"claude_stream_json": _parse_claude_stream_json,
|
|
732
|
+
"cursor_stream_json": _parse_cursor_stream_json,
|
|
733
|
+
"codex_json": _parse_codex_json,
|
|
734
|
+
"opencode_json": _parse_opencode_json,
|
|
735
|
+
"deveco_json": _parse_deveco_json,
|
|
736
|
+
"generic_json": _parse_generic_json,
|
|
737
|
+
"text": _parse_text_only,
|
|
738
|
+
}
|
|
739
|
+
if parser_name == "auto":
|
|
740
|
+
parser_name = detect_stream_parser(filepath)
|
|
741
|
+
parser = parsers.get(parser_name, _parse_claude_stream_json)
|
|
742
|
+
return parser(filepath)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _parse_claude_stream_json(filepath: Path) -> StreamStats:
|
|
746
|
+
"""Claude Code / Cursor Agent stream-json format."""
|
|
747
|
+
stats = StreamStats()
|
|
748
|
+
if not filepath.exists():
|
|
749
|
+
return stats
|
|
750
|
+
|
|
751
|
+
READ_TOOLS = {"Read", "read_file"}
|
|
752
|
+
WRITE_TOOLS = {"Edit", "Write", "edit_file", "write_file"}
|
|
753
|
+
SEARCH_TOOLS = {"Grep", "Glob", "grep", "glob", "search", "find"}
|
|
754
|
+
|
|
755
|
+
with open(filepath, errors="ignore") as f:
|
|
756
|
+
for line in f:
|
|
757
|
+
line = line.strip()
|
|
758
|
+
if not line: continue
|
|
759
|
+
try: event = json.loads(line)
|
|
760
|
+
except json.JSONDecodeError: continue
|
|
761
|
+
|
|
762
|
+
ev_type = event.get("type", "")
|
|
763
|
+
if ev_type == "system" and event.get("subtype") == "init":
|
|
764
|
+
stats.model = event.get("model", "")
|
|
765
|
+
|
|
766
|
+
if ev_type == "assistant":
|
|
767
|
+
stats.assistant_messages += 1
|
|
768
|
+
msg = event.get("message", {})
|
|
769
|
+
usage = msg.get("usage", {})
|
|
770
|
+
stats.total_input_tokens += usage.get("input_tokens", 0)
|
|
771
|
+
stats.total_output_tokens += usage.get("output_tokens", 0)
|
|
772
|
+
blocks = msg.get("content", [])
|
|
773
|
+
if isinstance(blocks, str):
|
|
774
|
+
blocks = [{"type": "text", "text": blocks}]
|
|
775
|
+
for block in blocks:
|
|
776
|
+
btype = block.get("type", "")
|
|
777
|
+
if btype == "tool_use":
|
|
778
|
+
stats.tool_calls += 1
|
|
779
|
+
stats.tool_names.append(block.get("name", ""))
|
|
780
|
+
inp = block.get("input", {})
|
|
781
|
+
if isinstance(inp, dict):
|
|
782
|
+
fp = inp.get("file_path", inp.get("filePath", ""))
|
|
783
|
+
if fp:
|
|
784
|
+
name = block.get("name", "")
|
|
785
|
+
if name in READ_TOOLS: stats.files_read.append(fp)
|
|
786
|
+
elif name in WRITE_TOOLS: stats.files_edited.append(fp)
|
|
787
|
+
elif name in SEARCH_TOOLS: stats.files_grepped.append(fp)
|
|
788
|
+
elif btype == "text":
|
|
789
|
+
stats.text_lines.append(block.get("text", ""))
|
|
790
|
+
elif btype == "thinking":
|
|
791
|
+
stats.thinking_lines.append(block.get("thinking", ""))
|
|
792
|
+
|
|
793
|
+
if ev_type == "user":
|
|
794
|
+
for block in event.get("message", {}).get("content", []):
|
|
795
|
+
if block.get("type") == "tool_result" and block.get("is_error"):
|
|
796
|
+
stats.errors.append(str(block.get("content", ""))[:200])
|
|
797
|
+
|
|
798
|
+
if ev_type == "error":
|
|
799
|
+
msg = event.get("message", str(event))
|
|
800
|
+
stats.errors.append(msg)
|
|
801
|
+
if "max turns" in msg.lower(): stats.max_turns_hit = True
|
|
802
|
+
|
|
803
|
+
if ev_type == "result":
|
|
804
|
+
if "max_turns" in event.get("subtype", "").lower():
|
|
805
|
+
stats.max_turns_hit = True
|
|
806
|
+
for e in event.get("errors", []):
|
|
807
|
+
stats.errors.append(str(e)[:200])
|
|
808
|
+
|
|
809
|
+
return stats
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _parse_codex_json(filepath: Path) -> StreamStats:
|
|
813
|
+
"""OpenAI Codex CLI JSON output format.
|
|
814
|
+
|
|
815
|
+
Codex outputs a JSON array of events or line-delimited JSON.
|
|
816
|
+
Each event has: {"type":"tool_call","tool":"read","path":"...","content":"..."}
|
|
817
|
+
"""
|
|
818
|
+
stats = StreamStats()
|
|
819
|
+
if not filepath.exists():
|
|
820
|
+
return stats
|
|
821
|
+
|
|
822
|
+
READ_TOOLS = {"read", "read_file", "cat"}
|
|
823
|
+
WRITE_TOOLS = {"edit", "write", "create"}
|
|
824
|
+
SEARCH_TOOLS = {"grep", "glob", "search", "find", "ls"}
|
|
825
|
+
|
|
826
|
+
raw = filepath.read_text(errors="ignore").strip()
|
|
827
|
+
|
|
828
|
+
# Try line-delimited JSON first
|
|
829
|
+
for line in raw.split("\n"):
|
|
830
|
+
line = line.strip()
|
|
831
|
+
if not line: continue
|
|
832
|
+
try:
|
|
833
|
+
event = json.loads(line)
|
|
834
|
+
except json.JSONDecodeError:
|
|
835
|
+
# Try parsing whole file as JSON array
|
|
836
|
+
try:
|
|
837
|
+
events = json.loads(raw)
|
|
838
|
+
if isinstance(events, list):
|
|
839
|
+
for event in events:
|
|
840
|
+
_parse_codex_event(event, stats, READ_TOOLS, WRITE_TOOLS, SEARCH_TOOLS)
|
|
841
|
+
return stats
|
|
842
|
+
except json.JSONDecodeError:
|
|
843
|
+
continue
|
|
844
|
+
continue
|
|
845
|
+
_parse_codex_event(event, stats, READ_TOOLS, WRITE_TOOLS, SEARCH_TOOLS)
|
|
846
|
+
|
|
847
|
+
return stats
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _parse_codex_event(event: dict, stats: StreamStats,
|
|
851
|
+
READ_TOOLS: set, WRITE_TOOLS: set, SEARCH_TOOLS: set):
|
|
852
|
+
ev_type = event.get("type", "")
|
|
853
|
+
if ev_type == "init":
|
|
854
|
+
stats.model = event.get("model", "")
|
|
855
|
+
if ev_type == "tool_call":
|
|
856
|
+
stats.tool_calls += 1
|
|
857
|
+
tool = event.get("tool", event.get("name", ""))
|
|
858
|
+
stats.tool_names.append(tool)
|
|
859
|
+
path = event.get("path", event.get("file", event.get("file_path", "")))
|
|
860
|
+
if path:
|
|
861
|
+
if tool in READ_TOOLS: stats.files_read.append(path)
|
|
862
|
+
elif tool in WRITE_TOOLS: stats.files_edited.append(path)
|
|
863
|
+
elif tool in SEARCH_TOOLS: stats.files_grepped.append(path)
|
|
864
|
+
if ev_type == "message" or ev_type == "assistant":
|
|
865
|
+
stats.assistant_messages += 1
|
|
866
|
+
text = event.get("content", event.get("text", ""))
|
|
867
|
+
if isinstance(text, str) and text:
|
|
868
|
+
stats.text_lines.append(text)
|
|
869
|
+
usage = event.get("usage", {})
|
|
870
|
+
stats.total_input_tokens += usage.get("input_tokens", usage.get("prompt_tokens", 0))
|
|
871
|
+
stats.total_output_tokens += usage.get("output_tokens", usage.get("completion_tokens", 0))
|
|
872
|
+
if ev_type == "error":
|
|
873
|
+
stats.errors.append(event.get("message", str(event)))
|
|
874
|
+
if "max turns" in event.get("message", "").lower():
|
|
875
|
+
stats.max_turns_hit = True
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _parse_opencode_json(filepath: Path) -> StreamStats:
|
|
879
|
+
"""OpenCode JSON output format.
|
|
880
|
+
|
|
881
|
+
Similar to Codex but may have different field names.
|
|
882
|
+
Handles both line-delimited and array formats.
|
|
883
|
+
"""
|
|
884
|
+
stats = StreamStats()
|
|
885
|
+
if not filepath.exists():
|
|
886
|
+
return stats
|
|
887
|
+
|
|
888
|
+
READ_TOOLS = {"read", "read_file", "open"}
|
|
889
|
+
WRITE_TOOLS = {"edit", "write", "create", "patch"}
|
|
890
|
+
SEARCH_TOOLS = {"grep", "glob", "search", "find", "list"}
|
|
891
|
+
|
|
892
|
+
raw = filepath.read_text(errors="ignore").strip()
|
|
893
|
+
for line in raw.split("\n"):
|
|
894
|
+
line = line.strip()
|
|
895
|
+
if not line: continue
|
|
896
|
+
try:
|
|
897
|
+
event = json.loads(line)
|
|
898
|
+
except json.JSONDecodeError:
|
|
899
|
+
continue
|
|
900
|
+
|
|
901
|
+
ev_type = event.get("type", event.get("event", ""))
|
|
902
|
+
if ev_type in ("init", "start"):
|
|
903
|
+
stats.model = event.get("model", event.get("llm", ""))
|
|
904
|
+
if ev_type in ("tool_call", "tool"):
|
|
905
|
+
stats.tool_calls += 1
|
|
906
|
+
tool = event.get("tool", event.get("name", ""))
|
|
907
|
+
stats.tool_names.append(tool)
|
|
908
|
+
path = event.get("path", event.get("file", event.get("file_path", "")))
|
|
909
|
+
if path:
|
|
910
|
+
if tool in READ_TOOLS: stats.files_read.append(path)
|
|
911
|
+
elif tool in WRITE_TOOLS: stats.files_edited.append(path)
|
|
912
|
+
elif tool in SEARCH_TOOLS: stats.files_grepped.append(path)
|
|
913
|
+
if ev_type in ("message", "assistant", "response"):
|
|
914
|
+
stats.assistant_messages += 1
|
|
915
|
+
text = event.get("content", event.get("text", event.get("message", "")))
|
|
916
|
+
if isinstance(text, str) and text:
|
|
917
|
+
stats.text_lines.append(text)
|
|
918
|
+
usage = event.get("usage", event.get("tokens", {}))
|
|
919
|
+
if isinstance(usage, dict):
|
|
920
|
+
stats.total_input_tokens += usage.get("input", usage.get("input_tokens", 0))
|
|
921
|
+
stats.total_output_tokens += usage.get("output", usage.get("output_tokens", 0))
|
|
922
|
+
if ev_type == "error":
|
|
923
|
+
msg = event.get("message", str(event))
|
|
924
|
+
stats.errors.append(msg)
|
|
925
|
+
if "max turns" in msg.lower(): stats.max_turns_hit = True
|
|
926
|
+
|
|
927
|
+
return stats
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _parse_deveco_json(filepath: Path) -> StreamStats:
|
|
931
|
+
"""DevEco Studio AI CLI JSON output format.
|
|
932
|
+
|
|
933
|
+
Actual deveco output (--format json):
|
|
934
|
+
{"type":"step_start", "part":{"type":"step-start", "sessionID":"...", ...}}
|
|
935
|
+
{"type":"tool_use", "part":{"type":"tool","tool":"read","state":{"status":"completed","input":{"filePath":"..."},...}}}
|
|
936
|
+
{"type":"step_finish", "part":{"type":"step-finish","reason":"tool-calls","tokens":{"total":...,"input":...,"output":...},...}}
|
|
937
|
+
{"type":"assistant", "part":{"type":"text","text":"Hello..."}}
|
|
938
|
+
|
|
939
|
+
Tool info is nested under ``part``, not at the event root.
|
|
940
|
+
"""
|
|
941
|
+
stats = StreamStats()
|
|
942
|
+
if not filepath.exists():
|
|
943
|
+
return stats
|
|
944
|
+
|
|
945
|
+
READ_TOOLS = {"read", "read_file", "Read", "open", "cat"}
|
|
946
|
+
WRITE_TOOLS = {"write", "edit", "Write", "Edit", "create", "replace"}
|
|
947
|
+
SEARCH_TOOLS = {"search", "find", "grep", "glob", "Grep", "Glob", "list", "ls", "bash"}
|
|
948
|
+
|
|
949
|
+
raw = filepath.read_text(errors="ignore").strip()
|
|
950
|
+
|
|
951
|
+
for line in raw.split("\n"):
|
|
952
|
+
line = line.strip()
|
|
953
|
+
if not line: continue
|
|
954
|
+
try: event = json.loads(line)
|
|
955
|
+
except json.JSONDecodeError: continue
|
|
956
|
+
|
|
957
|
+
ev_type = event.get("type", event.get("event", ""))
|
|
958
|
+
part = event.get("part", {}) if isinstance(event.get("part"), dict) else {}
|
|
959
|
+
|
|
960
|
+
session_id = event.get("sessionID", part.get("sessionID", ""))
|
|
961
|
+
if _is_deveco_session_id(session_id):
|
|
962
|
+
stats.deveco_session_id = session_id
|
|
963
|
+
|
|
964
|
+
if ev_type == "step_start":
|
|
965
|
+
if not stats.model:
|
|
966
|
+
model = event.get("model", part.get("model", ""))
|
|
967
|
+
if model and not _is_deveco_session_id(model):
|
|
968
|
+
stats.model = model
|
|
969
|
+
|
|
970
|
+
elif ev_type == "tool_use":
|
|
971
|
+
tool = part.get("tool", event.get("tool", ""))
|
|
972
|
+
if not tool:
|
|
973
|
+
continue
|
|
974
|
+
stats.tool_calls += 1
|
|
975
|
+
stats.tool_names.append(tool)
|
|
976
|
+
if is_homegraph_tool(tool):
|
|
977
|
+
stats.homegraph_tool_calls += 1
|
|
978
|
+
stats.used_homegraph = True
|
|
979
|
+
|
|
980
|
+
state = part.get("state", {})
|
|
981
|
+
inp = state.get("input", {}) if isinstance(state.get("input"), dict) else {}
|
|
982
|
+
|
|
983
|
+
if state.get("status") == "error":
|
|
984
|
+
err = state.get("error", "") or str(inp)
|
|
985
|
+
stats.errors.append(str(err)[:200])
|
|
986
|
+
|
|
987
|
+
# Extract file path from various input formats
|
|
988
|
+
fp = ""
|
|
989
|
+
if isinstance(inp, dict):
|
|
990
|
+
fp = inp.get("file_path", inp.get("filePath", inp.get("path", "")))
|
|
991
|
+
if not fp:
|
|
992
|
+
fp = event.get("path", event.get("file_path", ""))
|
|
993
|
+
|
|
994
|
+
if fp:
|
|
995
|
+
if tool.lower() in READ_TOOLS:
|
|
996
|
+
stats.files_read.append(fp)
|
|
997
|
+
elif tool.lower() in WRITE_TOOLS:
|
|
998
|
+
stats.files_edited.append(fp)
|
|
999
|
+
elif tool.lower() in SEARCH_TOOLS:
|
|
1000
|
+
stats.files_grepped.append(fp)
|
|
1001
|
+
|
|
1002
|
+
elif ev_type == "step_finish":
|
|
1003
|
+
tokens = part.get("tokens", {}) if isinstance(part.get("tokens"), dict) else {}
|
|
1004
|
+
if tokens:
|
|
1005
|
+
stats.total_input_tokens += tokens.get("input", 0)
|
|
1006
|
+
stats.total_output_tokens += tokens.get("output", 0)
|
|
1007
|
+
if part.get("reason") == "stop":
|
|
1008
|
+
# Final step — could extract summary here
|
|
1009
|
+
pass
|
|
1010
|
+
|
|
1011
|
+
elif ev_type in ("message", "assistant", "response", "text"):
|
|
1012
|
+
stats.assistant_messages += 1
|
|
1013
|
+
# Try nested part.text first, then flat content
|
|
1014
|
+
content = part.get("text", part.get("content", ""))
|
|
1015
|
+
if not content:
|
|
1016
|
+
content = event.get("content", event.get("text", event.get("message", "")))
|
|
1017
|
+
if isinstance(content, str) and content.strip():
|
|
1018
|
+
stats.text_lines.append(content)
|
|
1019
|
+
elif isinstance(content, list):
|
|
1020
|
+
for block in content:
|
|
1021
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
1022
|
+
stats.text_lines.append(block.get("text", ""))
|
|
1023
|
+
|
|
1024
|
+
elif ev_type == "error":
|
|
1025
|
+
msg = event.get("message", str(event))
|
|
1026
|
+
stats.errors.append(msg)
|
|
1027
|
+
if "max" in msg.lower() and ("turn" in msg.lower() or "step" in msg.lower()):
|
|
1028
|
+
stats.max_turns_hit = True
|
|
1029
|
+
|
|
1030
|
+
return stats
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _parse_generic_json(filepath: Path) -> StreamStats:
|
|
1034
|
+
"""Generic JSON parser — tries to auto-detect event structure.
|
|
1035
|
+
|
|
1036
|
+
Handles unknown agent formats by looking for common patterns:
|
|
1037
|
+
- Any event with a 'tool' field → tool call
|
|
1038
|
+
- Any event with 'content' → assistant message
|
|
1039
|
+
- Any event with 'error' → error
|
|
1040
|
+
"""
|
|
1041
|
+
stats = StreamStats()
|
|
1042
|
+
if not filepath.exists():
|
|
1043
|
+
return stats
|
|
1044
|
+
|
|
1045
|
+
raw = filepath.read_text(errors="ignore").strip()
|
|
1046
|
+
for line in raw.split("\n"):
|
|
1047
|
+
line = line.strip()
|
|
1048
|
+
if not line: continue
|
|
1049
|
+
try: event = json.loads(line)
|
|
1050
|
+
except json.JSONDecodeError: continue
|
|
1051
|
+
|
|
1052
|
+
# Detect tool calls
|
|
1053
|
+
tool = event.get("tool", event.get("name", event.get("function", "")))
|
|
1054
|
+
if tool:
|
|
1055
|
+
stats.tool_calls += 1
|
|
1056
|
+
stats.tool_names.append(tool)
|
|
1057
|
+
fp = event.get("file_path", event.get("path", event.get("file", "")))
|
|
1058
|
+
if fp:
|
|
1059
|
+
if tool.lower() in ("read", "open", "cat"): stats.files_read.append(fp)
|
|
1060
|
+
elif tool.lower() in ("write", "edit", "create", "replace"): stats.files_edited.append(fp)
|
|
1061
|
+
else: stats.files_grepped.append(fp)
|
|
1062
|
+
|
|
1063
|
+
# Detect messages
|
|
1064
|
+
content = event.get("content", event.get("text", event.get("message", "")))
|
|
1065
|
+
if isinstance(content, str) and content.strip():
|
|
1066
|
+
stats.assistant_messages += 1
|
|
1067
|
+
stats.text_lines.append(content)
|
|
1068
|
+
|
|
1069
|
+
# Detect tokens
|
|
1070
|
+
stats.total_input_tokens += event.get("input_tokens", event.get("prompt_tokens", 0))
|
|
1071
|
+
stats.total_output_tokens += event.get("output_tokens", event.get("completion_tokens", 0))
|
|
1072
|
+
|
|
1073
|
+
# Detect errors
|
|
1074
|
+
if event.get("error") or event.get("type") == "error":
|
|
1075
|
+
stats.errors.append(event.get("message", str(event)))
|
|
1076
|
+
|
|
1077
|
+
# Detect model
|
|
1078
|
+
if event.get("model"):
|
|
1079
|
+
stats.model = event["model"]
|
|
1080
|
+
|
|
1081
|
+
return stats
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _parse_text_only(filepath: Path) -> StreamStats:
|
|
1085
|
+
"""Plain-text output parser — no JSON, just count lines as content.
|
|
1086
|
+
|
|
1087
|
+
Used as fallback when agent only produces human-readable text.
|
|
1088
|
+
"""
|
|
1089
|
+
stats = StreamStats()
|
|
1090
|
+
if not filepath.exists():
|
|
1091
|
+
return stats
|
|
1092
|
+
|
|
1093
|
+
raw = filepath.read_text(errors="ignore").strip()
|
|
1094
|
+
for line in raw.split("\n"):
|
|
1095
|
+
line = line.strip()
|
|
1096
|
+
if line:
|
|
1097
|
+
stats.text_lines.append(line)
|
|
1098
|
+
|
|
1099
|
+
stats.assistant_messages = 1
|
|
1100
|
+
# Heuristic: count lines with file paths as "reads"
|
|
1101
|
+
for line in stats.text_lines:
|
|
1102
|
+
if re.search(r"\.(ets|ts|js|json5?|cpp|h|hpp|py|md)", line):
|
|
1103
|
+
stats.files_read.append(line)
|
|
1104
|
+
|
|
1105
|
+
return stats
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
# ═══════════════════════════════════════════════════════════
|
|
1109
|
+
# Backward-compatible alias
|
|
1110
|
+
# ═══════════════════════════════════════════════════════════
|
|
1111
|
+
|
|
1112
|
+
def parse_stream_json(filepath: Path) -> StreamStats:
|
|
1113
|
+
"""Parse stream-json, auto-detecting Claude vs Cursor format."""
|
|
1114
|
+
return parse_output(filepath, "auto")
|
|
1115
|
+
|
|
1116
|
+
build_claude_cmd = None # deprecated, use build_agent_cmd instead
|