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.
Files changed (361) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +268 -243
  3. package/dist/arkts/ohos-api-index.d.ts +15 -0
  4. package/dist/arkts/ohos-api-index.d.ts.map +1 -0
  5. package/dist/arkts/ohos-api-index.js +190 -0
  6. package/dist/arkts/ohos-api-index.js.map +1 -0
  7. package/dist/arkts/ohos-sdk-input.d.ts +36 -0
  8. package/dist/arkts/ohos-sdk-input.d.ts.map +1 -0
  9. package/dist/arkts/ohos-sdk-input.js +214 -0
  10. package/dist/arkts/ohos-sdk-input.js.map +1 -0
  11. package/dist/bin/command-supervision.d.ts +12 -0
  12. package/dist/bin/command-supervision.d.ts.map +1 -0
  13. package/dist/bin/command-supervision.js +86 -0
  14. package/dist/bin/command-supervision.js.map +1 -0
  15. package/dist/bin/homegraph.d.ts +1 -1
  16. package/dist/bin/homegraph.js +971 -71
  17. package/dist/bin/homegraph.js.map +1 -1
  18. package/dist/db/index.d.ts +20 -0
  19. package/dist/db/index.d.ts.map +1 -1
  20. package/dist/db/index.js +39 -0
  21. package/dist/db/index.js.map +1 -1
  22. package/dist/db/migrations.d.ts +1 -1
  23. package/dist/db/migrations.d.ts.map +1 -1
  24. package/dist/db/migrations.js +58 -19
  25. package/dist/db/migrations.js.map +1 -1
  26. package/dist/db/queries.d.ts +14 -0
  27. package/dist/db/queries.d.ts.map +1 -1
  28. package/dist/db/queries.js +314 -143
  29. package/dist/db/queries.js.map +1 -1
  30. package/dist/db/schema.sql +172 -152
  31. package/dist/directory.d.ts +32 -0
  32. package/dist/directory.d.ts.map +1 -1
  33. package/dist/directory.js +88 -5
  34. package/dist/directory.js.map +1 -1
  35. package/dist/extraction/arkts-batch-worker.d.ts +2 -0
  36. package/dist/extraction/arkts-batch-worker.d.ts.map +1 -0
  37. package/dist/extraction/arkts-batch-worker.js +28 -0
  38. package/dist/extraction/arkts-batch-worker.js.map +1 -0
  39. package/dist/extraction/context.d.ts +11 -0
  40. package/dist/extraction/context.d.ts.map +1 -1
  41. package/dist/extraction/context.js +20 -0
  42. package/dist/extraction/context.js.map +1 -1
  43. package/dist/extraction/index.d.ts +16 -2
  44. package/dist/extraction/index.d.ts.map +1 -1
  45. package/dist/extraction/index.js +619 -380
  46. package/dist/extraction/index.js.map +1 -1
  47. package/dist/extraction/languages/arkts-state-decorators.d.ts +13 -0
  48. package/dist/extraction/languages/arkts-state-decorators.d.ts.map +1 -0
  49. package/dist/extraction/languages/arkts-state-decorators.js +26 -0
  50. package/dist/extraction/languages/arkts-state-decorators.js.map +1 -0
  51. package/dist/extraction/languages/arkts-viewtree.d.ts +4 -2
  52. package/dist/extraction/languages/arkts-viewtree.d.ts.map +1 -1
  53. package/dist/extraction/languages/arkts-viewtree.js +21 -6
  54. package/dist/extraction/languages/arkts-viewtree.js.map +1 -1
  55. package/dist/extraction/languages/arkts.d.ts +98 -2
  56. package/dist/extraction/languages/arkts.d.ts.map +1 -1
  57. package/dist/extraction/languages/arkts.js +1290 -61
  58. package/dist/extraction/languages/arkts.js.map +1 -1
  59. package/dist/extraction/languages/c-cpp.d.ts +56 -0
  60. package/dist/extraction/languages/c-cpp.d.ts.map +1 -1
  61. package/dist/extraction/languages/c-cpp.js +198 -1
  62. package/dist/extraction/languages/c-cpp.js.map +1 -1
  63. package/dist/extraction/languages/ohos-api-consumer.d.ts +34 -0
  64. package/dist/extraction/languages/ohos-api-consumer.d.ts.map +1 -0
  65. package/dist/extraction/languages/ohos-api-consumer.js +283 -0
  66. package/dist/extraction/languages/ohos-api-consumer.js.map +1 -0
  67. package/dist/extraction/parse-pool.d.ts +126 -0
  68. package/dist/extraction/parse-pool.d.ts.map +1 -0
  69. package/dist/extraction/parse-pool.js +319 -0
  70. package/dist/extraction/parse-pool.js.map +1 -0
  71. package/dist/extraction/tree-sitter-types.d.ts +17 -0
  72. package/dist/extraction/tree-sitter-types.d.ts.map +1 -1
  73. package/dist/extraction/tree-sitter.d.ts +21 -0
  74. package/dist/extraction/tree-sitter.d.ts.map +1 -1
  75. package/dist/extraction/tree-sitter.js +198 -27
  76. package/dist/extraction/tree-sitter.js.map +1 -1
  77. package/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  78. package/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  79. package/dist/graph/traversal.d.ts.map +1 -1
  80. package/dist/graph/traversal.js +76 -17
  81. package/dist/graph/traversal.js.map +1 -1
  82. package/dist/index.d.ts +25 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +78 -4
  85. package/dist/index.js.map +1 -1
  86. package/dist/installer/instructions-template.js +9 -9
  87. package/dist/installer/targets/shared.d.ts +5 -6
  88. package/dist/installer/targets/shared.d.ts.map +1 -1
  89. package/dist/installer/targets/shared.js +5 -6
  90. package/dist/installer/targets/shared.js.map +1 -1
  91. package/dist/mcp/daemon-paths.d.ts +30 -3
  92. package/dist/mcp/daemon-paths.d.ts.map +1 -1
  93. package/dist/mcp/daemon-paths.js +50 -10
  94. package/dist/mcp/daemon-paths.js.map +1 -1
  95. package/dist/mcp/daemon-registry.d.ts.map +1 -1
  96. package/dist/mcp/daemon-registry.js +7 -3
  97. package/dist/mcp/daemon-registry.js.map +1 -1
  98. package/dist/mcp/daemon.d.ts +48 -0
  99. package/dist/mcp/daemon.d.ts.map +1 -1
  100. package/dist/mcp/daemon.js +196 -32
  101. package/dist/mcp/daemon.js.map +1 -1
  102. package/dist/mcp/engine.d.ts +17 -0
  103. package/dist/mcp/engine.d.ts.map +1 -1
  104. package/dist/mcp/engine.js +73 -1
  105. package/dist/mcp/engine.js.map +1 -1
  106. package/dist/mcp/index.d.ts.map +1 -1
  107. package/dist/mcp/index.js +25 -43
  108. package/dist/mcp/index.js.map +1 -1
  109. package/dist/mcp/liveness-watchdog.js +16 -16
  110. package/dist/mcp/ppid-watchdog.d.ts +18 -0
  111. package/dist/mcp/ppid-watchdog.d.ts.map +1 -1
  112. package/dist/mcp/ppid-watchdog.js +37 -0
  113. package/dist/mcp/ppid-watchdog.js.map +1 -1
  114. package/dist/mcp/query-cache.d.ts +25 -0
  115. package/dist/mcp/query-cache.d.ts.map +1 -0
  116. package/dist/mcp/query-cache.js +191 -0
  117. package/dist/mcp/query-cache.js.map +1 -0
  118. package/dist/mcp/query-pool.d.ts +94 -0
  119. package/dist/mcp/query-pool.d.ts.map +1 -0
  120. package/dist/mcp/query-pool.js +297 -0
  121. package/dist/mcp/query-pool.js.map +1 -0
  122. package/dist/mcp/query-worker.d.ts +24 -0
  123. package/dist/mcp/query-worker.d.ts.map +1 -0
  124. package/dist/mcp/query-worker.js +87 -0
  125. package/dist/mcp/query-worker.js.map +1 -0
  126. package/dist/mcp/server-instructions.d.ts +5 -7
  127. package/dist/mcp/server-instructions.d.ts.map +1 -1
  128. package/dist/mcp/server-instructions.js +72 -74
  129. package/dist/mcp/server-instructions.js.map +1 -1
  130. package/dist/mcp/tools.d.ts +94 -4
  131. package/dist/mcp/tools.d.ts.map +1 -1
  132. package/dist/mcp/tools.js +877 -71
  133. package/dist/mcp/tools.js.map +1 -1
  134. package/dist/project-config.d.ts +20 -0
  135. package/dist/project-config.d.ts.map +1 -1
  136. package/dist/project-config.js +42 -2
  137. package/dist/project-config.js.map +1 -1
  138. package/dist/reasoning/login.js +1 -1
  139. package/dist/reasoning/login.js.map +1 -1
  140. package/dist/reasoning/reasoner.js +32 -32
  141. package/dist/resolution/c-fnptr-synthesizer.d.ts +0 -28
  142. package/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -1
  143. package/dist/resolution/c-fnptr-synthesizer.js +765 -79
  144. package/dist/resolution/c-fnptr-synthesizer.js.map +1 -1
  145. package/dist/resolution/callback-synthesizer.d.ts +1 -1
  146. package/dist/resolution/callback-synthesizer.d.ts.map +1 -1
  147. package/dist/resolution/callback-synthesizer.js +72 -11
  148. package/dist/resolution/callback-synthesizer.js.map +1 -1
  149. package/dist/resolution/cooperative-yield.d.ts +32 -0
  150. package/dist/resolution/cooperative-yield.d.ts.map +1 -0
  151. package/dist/resolution/cooperative-yield.js +42 -0
  152. package/dist/resolution/cooperative-yield.js.map +1 -0
  153. package/dist/resolution/index.d.ts +11 -2
  154. package/dist/resolution/index.d.ts.map +1 -1
  155. package/dist/resolution/index.js +72 -4
  156. package/dist/resolution/index.js.map +1 -1
  157. package/dist/resolution/name-matcher.d.ts +22 -0
  158. package/dist/resolution/name-matcher.d.ts.map +1 -1
  159. package/dist/resolution/name-matcher.js +317 -20
  160. package/dist/resolution/name-matcher.js.map +1 -1
  161. package/dist/spec/config.d.ts +39 -0
  162. package/dist/spec/config.d.ts.map +1 -0
  163. package/dist/spec/config.js +304 -0
  164. package/dist/spec/config.js.map +1 -0
  165. package/dist/spec/db/commit-node.d.ts +23 -0
  166. package/dist/spec/db/commit-node.d.ts.map +1 -0
  167. package/dist/spec/db/commit-node.js +62 -0
  168. package/dist/spec/db/commit-node.js.map +1 -0
  169. package/dist/spec/db/fragment-node.d.ts +24 -0
  170. package/dist/spec/db/fragment-node.d.ts.map +1 -0
  171. package/dist/spec/db/fragment-node.js +128 -0
  172. package/dist/spec/db/fragment-node.js.map +1 -0
  173. package/dist/spec/db/fts.d.ts +74 -0
  174. package/dist/spec/db/fts.d.ts.map +1 -0
  175. package/dist/spec/db/fts.js +324 -0
  176. package/dist/spec/db/fts.js.map +1 -0
  177. package/dist/spec/db/index.d.ts +13 -0
  178. package/dist/spec/db/index.d.ts.map +1 -0
  179. package/dist/spec/db/index.js +50 -0
  180. package/dist/spec/db/index.js.map +1 -0
  181. package/dist/spec/db/relations.d.ts +55 -0
  182. package/dist/spec/db/relations.d.ts.map +1 -0
  183. package/dist/spec/db/relations.js +158 -0
  184. package/dist/spec/db/relations.js.map +1 -0
  185. package/dist/spec/db/schema.d.ts +33 -0
  186. package/dist/spec/db/schema.d.ts.map +1 -0
  187. package/dist/spec/db/schema.js +119 -0
  188. package/dist/spec/db/schema.js.map +1 -0
  189. package/dist/spec/db/schema.sql +117 -0
  190. package/dist/spec/db/spec-node.d.ts +41 -0
  191. package/dist/spec/db/spec-node.d.ts.map +1 -0
  192. package/dist/spec/db/spec-node.js +114 -0
  193. package/dist/spec/db/spec-node.js.map +1 -0
  194. package/dist/spec/evolve/impact-locator.d.ts +13 -0
  195. package/dist/spec/evolve/impact-locator.d.ts.map +1 -0
  196. package/dist/spec/evolve/impact-locator.js +25 -0
  197. package/dist/spec/evolve/impact-locator.js.map +1 -0
  198. package/dist/spec/evolve/llm-client.d.ts +50 -0
  199. package/dist/spec/evolve/llm-client.d.ts.map +1 -0
  200. package/dist/spec/evolve/llm-client.js +176 -0
  201. package/dist/spec/evolve/llm-client.js.map +1 -0
  202. package/dist/spec/evolve/logic-checker.d.ts +12 -0
  203. package/dist/spec/evolve/logic-checker.d.ts.map +1 -0
  204. package/dist/spec/evolve/logic-checker.js +24 -0
  205. package/dist/spec/evolve/logic-checker.js.map +1 -0
  206. package/dist/spec/evolve/pipeline.d.ts +42 -0
  207. package/dist/spec/evolve/pipeline.d.ts.map +1 -0
  208. package/dist/spec/evolve/pipeline.js +567 -0
  209. package/dist/spec/evolve/pipeline.js.map +1 -0
  210. package/dist/spec/evolve/spec-rewriter.d.ts +42 -0
  211. package/dist/spec/evolve/spec-rewriter.d.ts.map +1 -0
  212. package/dist/spec/evolve/spec-rewriter.js +230 -0
  213. package/dist/spec/evolve/spec-rewriter.js.map +1 -0
  214. package/dist/spec/graph/queries.d.ts +155 -0
  215. package/dist/spec/graph/queries.d.ts.map +1 -0
  216. package/dist/spec/graph/queries.js +440 -0
  217. package/dist/spec/graph/queries.js.map +1 -0
  218. package/dist/spec/llm/client.d.ts +29 -0
  219. package/dist/spec/llm/client.d.ts.map +1 -0
  220. package/dist/spec/llm/client.js +123 -0
  221. package/dist/spec/llm/client.js.map +1 -0
  222. package/dist/spec/llm/index.d.ts +3 -0
  223. package/dist/spec/llm/index.d.ts.map +1 -0
  224. package/dist/spec/llm/index.js +11 -0
  225. package/dist/spec/llm/index.js.map +1 -0
  226. package/dist/spec/llm/prompts.d.ts +13 -0
  227. package/dist/spec/llm/prompts.d.ts.map +1 -0
  228. package/dist/spec/llm/prompts.js +75 -0
  229. package/dist/spec/llm/prompts.js.map +1 -0
  230. package/dist/spec/mining/diff-parser.d.ts +33 -0
  231. package/dist/spec/mining/diff-parser.d.ts.map +1 -0
  232. package/dist/spec/mining/diff-parser.js +166 -0
  233. package/dist/spec/mining/diff-parser.js.map +1 -0
  234. package/dist/spec/mining/git-scanner.d.ts +103 -0
  235. package/dist/spec/mining/git-scanner.d.ts.map +1 -0
  236. package/dist/spec/mining/git-scanner.js +307 -0
  237. package/dist/spec/mining/git-scanner.js.map +1 -0
  238. package/dist/spec/mining/pipeline.d.ts +53 -0
  239. package/dist/spec/mining/pipeline.d.ts.map +1 -0
  240. package/dist/spec/mining/pipeline.js +178 -0
  241. package/dist/spec/mining/pipeline.js.map +1 -0
  242. package/dist/spec/mining/scope-resolver.d.ts +45 -0
  243. package/dist/spec/mining/scope-resolver.d.ts.map +1 -0
  244. package/dist/spec/mining/scope-resolver.js +103 -0
  245. package/dist/spec/mining/scope-resolver.js.map +1 -0
  246. package/dist/spec/mining/spec-extractor.d.ts +69 -0
  247. package/dist/spec/mining/spec-extractor.d.ts.map +1 -0
  248. package/dist/spec/mining/spec-extractor.js +369 -0
  249. package/dist/spec/mining/spec-extractor.js.map +1 -0
  250. package/dist/spec/types.d.ts +149 -0
  251. package/dist/spec/types.d.ts.map +1 -0
  252. package/dist/spec/types.js +15 -0
  253. package/dist/spec/types.js.map +1 -0
  254. package/dist/spec/utils.d.ts +167 -0
  255. package/dist/spec/utils.d.ts.map +1 -0
  256. package/dist/spec/utils.js +463 -0
  257. package/dist/spec/utils.js.map +1 -0
  258. package/dist/sync/worktree.d.ts +9 -0
  259. package/dist/sync/worktree.d.ts.map +1 -1
  260. package/dist/sync/worktree.js +40 -0
  261. package/dist/sync/worktree.js.map +1 -1
  262. package/dist/types.d.ts +6 -1
  263. package/dist/types.d.ts.map +1 -1
  264. package/dist/ui/shimmer-progress.d.ts +2 -0
  265. package/dist/ui/shimmer-progress.d.ts.map +1 -1
  266. package/dist/ui/shimmer-progress.js +19 -2
  267. package/dist/ui/shimmer-progress.js.map +1 -1
  268. package/dist/upgrade/index.js +1 -1
  269. package/dist/upgrade/index.js.map +1 -1
  270. package/package.json +58 -57
  271. package/scripts/add-lang/bench.sh +60 -60
  272. package/scripts/add-lang/check-grammar.mjs +75 -75
  273. package/scripts/add-lang/dump-ast.mjs +103 -103
  274. package/scripts/add-lang/verify-extraction.mjs +70 -70
  275. package/scripts/agent-eval/ab-adoption.sh +91 -91
  276. package/scripts/agent-eval/ab-hook.sh +86 -86
  277. package/scripts/agent-eval/ab-impl.sh +78 -78
  278. package/scripts/agent-eval/ab-new-vs-baseline.sh +102 -102
  279. package/scripts/agent-eval/ab-sufficiency.sh +78 -78
  280. package/scripts/agent-eval/arms-F.sh +21 -21
  281. package/scripts/agent-eval/arms-matrix.sh +37 -37
  282. package/scripts/agent-eval/audit.sh +68 -68
  283. package/scripts/agent-eval/bench-readme.sh +28 -28
  284. package/scripts/agent-eval/bench-why-repo.sh +22 -22
  285. package/scripts/agent-eval/block-read-hook.sh +19 -19
  286. package/scripts/agent-eval/hook-settings.json +15 -15
  287. package/scripts/agent-eval/itrun.sh +120 -120
  288. package/scripts/agent-eval/offload-eval-3arm.sh +72 -72
  289. package/scripts/agent-eval/offload-eval-cost.mjs +133 -133
  290. package/scripts/agent-eval/offload-eval-effort.mjs +108 -108
  291. package/scripts/agent-eval/offload-eval-frontload-matrix.sh +25 -25
  292. package/scripts/agent-eval/offload-eval-frontload.sh +47 -47
  293. package/scripts/agent-eval/offload-eval-ground-truth.json +18 -18
  294. package/scripts/agent-eval/offload-eval-hook.mjs +84 -84
  295. package/scripts/agent-eval/offload-eval-judge.mjs +103 -103
  296. package/scripts/agent-eval/offload-eval-matrix.sh +20 -20
  297. package/scripts/agent-eval/offload-eval-metrics.mjs +94 -94
  298. package/scripts/agent-eval/offload-eval-refs1.sh +50 -50
  299. package/scripts/agent-eval/offload-eval-setup.sh +24 -24
  300. package/scripts/agent-eval/offload-eval-styles.sh +71 -71
  301. package/scripts/agent-eval/offload-eval-summarize.mjs +68 -68
  302. package/scripts/agent-eval/offload-eval.md +76 -76
  303. package/scripts/agent-eval/parse-arms.mjs +116 -116
  304. package/scripts/agent-eval/parse-bench-readme.mjs +84 -84
  305. package/scripts/agent-eval/parse-run.mjs +45 -45
  306. package/scripts/agent-eval/parse-session.mjs +93 -93
  307. package/scripts/agent-eval/probe-context.mjs +21 -21
  308. package/scripts/agent-eval/probe-explore.mjs +40 -40
  309. package/scripts/agent-eval/probe-node.mjs +20 -20
  310. package/scripts/agent-eval/probe-sweep.mjs +119 -119
  311. package/scripts/agent-eval/probe-trace.mjs +20 -20
  312. package/scripts/agent-eval/redirect-read-hook.sh +38 -38
  313. package/scripts/agent-eval/repro-concurrent-explore.mjs +119 -0
  314. package/scripts/agent-eval/repro-daemon-clients.mjs +125 -0
  315. package/scripts/agent-eval/run-agent.sh +34 -34
  316. package/scripts/agent-eval/run-all.sh +69 -69
  317. package/scripts/agent-eval/run-arms.sh +56 -56
  318. package/scripts/agent-eval/seq-matrix.mjs +137 -137
  319. package/scripts/build-bundle.sh +118 -118
  320. package/scripts/exp_boundary_eval/README.md +247 -0
  321. package/scripts/exp_boundary_eval/__pycache__/_utils.cpython-310.pyc +0 -0
  322. package/scripts/exp_boundary_eval/__pycache__/analyze.cpython-310.pyc +0 -0
  323. package/scripts/exp_boundary_eval/__pycache__/deveco_arm.cpython-310.pyc +0 -0
  324. package/scripts/exp_boundary_eval/__pycache__/run_one.cpython-310.pyc +0 -0
  325. package/scripts/exp_boundary_eval/__pycache__/run_session.cpython-310.pyc +0 -0
  326. package/scripts/exp_boundary_eval/__pycache__/setup.cpython-310.pyc +0 -0
  327. package/scripts/exp_boundary_eval/_test_mcp_chain.py +78 -0
  328. package/scripts/exp_boundary_eval/_test_stdin.py +8 -0
  329. package/scripts/exp_boundary_eval/_utils.py +1116 -0
  330. package/scripts/exp_boundary_eval/analyze.py +1313 -0
  331. package/scripts/exp_boundary_eval/data/agents.json +109 -0
  332. package/scripts/exp_boundary_eval/data/experiments.json +140 -0
  333. package/scripts/exp_boundary_eval/deveco_arm.py +519 -0
  334. package/scripts/exp_boundary_eval/run_all.py +378 -0
  335. package/scripts/exp_boundary_eval/run_one.py +165 -0
  336. package/scripts/exp_boundary_eval/run_session.py +158 -0
  337. package/scripts/exp_boundary_eval/setup.py +120 -0
  338. package/scripts/exp_boundary_eval/win_mcp_launcher.py +73 -0
  339. package/scripts/exp_boundary_eval/win_mcp_stdio_wrap.js +36 -0
  340. package/scripts/exp_boundary_eval/win_node_launcher.py +24 -0
  341. package/scripts/extract-release-notes.mjs +130 -130
  342. package/scripts/local-install.sh +41 -41
  343. package/scripts/npm-sdk.js +75 -75
  344. package/scripts/npm-shim.js +268 -246
  345. package/scripts/ohos-sdk-publish.mjs +133 -0
  346. package/scripts/pack-npm.sh +119 -119
  347. package/scripts/prepare-release.mjs +270 -270
  348. package/scripts/qa_eval/README.md +407 -404
  349. package/scripts/qa_eval/_test_deveco_probe.py +41 -41
  350. package/scripts/qa_eval/agent_runner.py +526 -526
  351. package/scripts/qa_eval/data/.gitignore +4 -4
  352. package/scripts/qa_eval/data/test-set.jsonl +2 -22
  353. package/scripts/qa_eval/eval_metrics.py +274 -233
  354. package/scripts/qa_eval/external_agent.py +976 -671
  355. package/scripts/qa_eval/llm_config.py +92 -92
  356. package/scripts/qa_eval/memory_monitor.py +132 -132
  357. package/scripts/qa_eval/my_answer_accuracy.py +187 -187
  358. package/scripts/qa_eval/requirements.txt +2 -2
  359. package/scripts/qa_eval/run_pipeline.py +804 -711
  360. package/scripts/qa_eval/stats_efficiency.py +279 -279
  361. 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