foxclaw 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. package/index.js +1 -8
  2. package/package.json +1 -1
  3. package/python/.env.example +8 -0
  4. package/python/.github/workflows/ci.yml +47 -2
  5. package/python/.github/workflows/release.yml +5 -0
  6. package/python/CHANGELOG.md +100 -0
  7. package/python/CHANGELOG_CN.md +100 -0
  8. package/python/README.md +17 -2
  9. package/python/README_CN.md +38 -20
  10. package/python/bench/bm25_audit.py +1 -14
  11. package/python/bench/bm25_regression.py +0 -1
  12. package/python/bench/bm25_stress.py +0 -4
  13. package/python/bench/cd_audit.py +0 -23
  14. package/python/bench/chaos.py +6 -8
  15. package/python/bench/e2e_real_models.py +0 -1
  16. package/python/bench/llm_retry_test.py +2 -9
  17. package/python/bench/load_test.py +5 -8
  18. package/python/bench/vector_ivf_upgrade.py +1 -7
  19. package/python/docs/CONTENT_GUARD.md +189 -0
  20. package/python/docs/CONTENT_GUARD_CN.md +189 -0
  21. package/python/docs/RELEASE.md +78 -37
  22. package/python/docs/RUN_CN.md +2 -2
  23. package/python/evals/governance/cases.py +0 -37
  24. package/python/evals/governance/run_evals.py +5 -37
  25. package/python/examples/governance_middleware_demo.py +1 -6
  26. package/python/examples/langgraph_governance_demo.py +7 -11
  27. package/python/foxclaw/__init__.py +74 -152
  28. package/python/foxclaw/agents/base.py +82 -136
  29. package/python/foxclaw/agents/impl/_helpers.py +2 -10
  30. package/python/foxclaw/agents/impl/finance.py +4 -21
  31. package/python/foxclaw/agents/impl/office.py +7 -24
  32. package/python/foxclaw/agents/impl/supervisor.py +12 -79
  33. package/python/foxclaw/agents/mixins/_context.py +0 -23
  34. package/python/foxclaw/agents/mixins/augment.py +10 -16
  35. package/python/foxclaw/agents/mixins/llm.py +7 -6
  36. package/python/foxclaw/agents/mixins/memory.py +23 -3
  37. package/python/foxclaw/agents/mixins/preference.py +58 -8
  38. package/python/foxclaw/agents/mixins/prompt.py +59 -12
  39. package/python/foxclaw/agents/mixins/skillbox.py +6 -6
  40. package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
  41. package/python/foxclaw/agents/rules/finance/RULES.md +131 -0
  42. package/python/foxclaw/api/__init__.py +18 -77
  43. package/python/foxclaw/api/_state.py +43 -0
  44. package/python/foxclaw/api/bootstrap.py +114 -0
  45. package/python/foxclaw/api/middleware.py +5 -28
  46. package/python/foxclaw/api/routes/ahs.py +60 -73
  47. package/python/foxclaw/api/routes/attachments.py +5 -6
  48. package/python/foxclaw/api/routes/browser.py +7 -11
  49. package/python/foxclaw/api/routes/domain.py +18 -26
  50. package/python/foxclaw/api/routes/knowledge.py +3 -21
  51. package/python/foxclaw/api/routes/llm_admin.py +21 -33
  52. package/python/foxclaw/api/routes/memory.py +50 -12
  53. package/python/foxclaw/api/routes/orchestration.py +3 -5
  54. package/python/foxclaw/api/routes/recovery.py +4 -3
  55. package/python/foxclaw/api/routes/review.py +2 -11
  56. package/python/foxclaw/api/routes/search.py +3 -7
  57. package/python/foxclaw/api/routes/system.py +33 -49
  58. package/python/foxclaw/api/routes/task.py +31 -49
  59. package/python/foxclaw/api/routes/ws.py +2 -6
  60. package/python/foxclaw/api/spine.py +1 -4
  61. package/python/foxclaw/behavior/__init__.py +20 -0
  62. package/python/foxclaw/behavior/behavior.yaml +62 -0
  63. package/python/foxclaw/behavior/spec.py +287 -0
  64. package/python/foxclaw/browser/action/action_executor.py +1 -5
  65. package/python/foxclaw/browser/action/action_types.py +0 -1
  66. package/python/foxclaw/browser/action/url_policy.py +1 -5
  67. package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
  68. package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
  69. package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
  70. package/python/foxclaw/browser/perception/element_indexer.py +1 -2
  71. package/python/foxclaw/browser/service.py +7 -11
  72. package/python/foxclaw/capability/embedding_service.py +4 -6
  73. package/python/foxclaw/channels/__init__.py +9 -11
  74. package/python/foxclaw/channels/discord.py +4 -13
  75. package/python/foxclaw/channels/dispatcher.py +2 -27
  76. package/python/foxclaw/channels/feishu.py +10 -20
  77. package/python/foxclaw/channels/openclaw_bridge.py +4 -9
  78. package/python/foxclaw/channels/signing.py +9 -9
  79. package/python/foxclaw/channels/telegram.py +3 -4
  80. package/python/foxclaw/channels/weixin.py +7 -17
  81. package/python/foxclaw/cli.py +42 -13
  82. package/python/foxclaw/cognitive/reasoner.py +9 -10
  83. package/python/foxclaw/cognitive/state.py +18 -24
  84. package/python/foxclaw/core/audit_chain.py +1 -3
  85. package/python/foxclaw/core/audit_sink.py +3 -3
  86. package/python/foxclaw/core/compaction.py +8 -26
  87. package/python/foxclaw/core/config.py +2 -2
  88. package/python/foxclaw/core/confirm_nonce.py +6 -12
  89. package/python/foxclaw/core/content_guard.py +341 -0
  90. package/python/foxclaw/core/envfile.py +2 -2
  91. package/python/foxclaw/core/failure_policy.py +17 -26
  92. package/python/foxclaw/core/gateway.py +6 -10
  93. package/python/foxclaw/core/governance_audit.py +3 -7
  94. package/python/foxclaw/core/idempotency.py +15 -22
  95. package/python/foxclaw/core/injection_guard.py +10 -29
  96. package/python/foxclaw/core/memory_adapter.py +53 -60
  97. package/python/foxclaw/core/metrics.py +4 -5
  98. package/python/foxclaw/core/model_router.py +74 -150
  99. package/python/foxclaw/core/paths.py +1 -1
  100. package/python/foxclaw/core/recovery.py +21 -34
  101. package/python/foxclaw/core/scheduler.py +23 -26
  102. package/python/foxclaw/core/secrets.py +0 -3
  103. package/python/foxclaw/core/sensitive_write.py +283 -0
  104. package/python/foxclaw/core/sessions.py +4 -6
  105. package/python/foxclaw/core/settings.py +16 -32
  106. package/python/foxclaw/core/shared_write.py +11 -23
  107. package/python/foxclaw/core/steering_queue.py +12 -11
  108. package/python/foxclaw/core/system_bus.py +1 -1
  109. package/python/foxclaw/core/thought_chain.py +30 -32
  110. package/python/foxclaw/core/token_meter.py +9 -13
  111. package/python/foxclaw/core/tools.py +4 -12
  112. package/python/foxclaw/core/tracing.py +25 -40
  113. package/python/foxclaw/core/truth_pipeline.py +5 -9
  114. package/python/foxclaw/core/update_check.py +149 -0
  115. package/python/foxclaw/core/vec_recall_adapter.py +11 -37
  116. package/python/foxclaw/core/verify.py +6 -14
  117. package/python/foxclaw/core/web_search.py +4 -28
  118. package/python/foxclaw/finance/evidence.py +14 -18
  119. package/python/foxclaw/finance/fx.py +1 -3
  120. package/python/foxclaw/finance/graph.py +9 -18
  121. package/python/foxclaw/finance/models.py +20 -24
  122. package/python/foxclaw/finance/nlparse.py +12 -72
  123. package/python/foxclaw/finance/policy.py +8 -36
  124. package/python/foxclaw/finance/reasoner.py +3 -11
  125. package/python/foxclaw/finance/reasoning.py +13 -34
  126. package/python/foxclaw/finance/reconcile.py +5 -10
  127. package/python/foxclaw/finance/runtime.py +19 -46
  128. package/python/foxclaw/governance/middleware.py +13 -21
  129. package/python/foxclaw/harness/_base.py +2 -8
  130. package/python/foxclaw/harness/_policy.py +19 -38
  131. package/python/foxclaw/harness/drift.py +1 -13
  132. package/python/foxclaw/harness/event_bus.py +2 -5
  133. package/python/foxclaw/harness/harness.py +9 -97
  134. package/python/foxclaw/harness/loop_detection.py +11 -18
  135. package/python/foxclaw/itinerary/amap_client.py +2 -5
  136. package/python/foxclaw/itinerary/models.py +7 -7
  137. package/python/foxclaw/itinerary/reasoner.py +1 -6
  138. package/python/foxclaw/itinerary/reasoning.py +15 -21
  139. package/python/foxclaw/itinerary/runtime.py +8 -12
  140. package/python/foxclaw/kernel/capabilities.py +5 -6
  141. package/python/foxclaw/kernel/policy_gate.py +4 -10
  142. package/python/foxclaw/kernel/process_manager.py +17 -36
  143. package/python/foxclaw/kernel/scheduler.py +3 -9
  144. package/python/foxclaw/kernel/sentinel.py +13 -23
  145. package/python/foxclaw/kernel/sentinel_client.py +12 -15
  146. package/python/foxclaw/kernel/spine.py +9 -9
  147. package/python/foxclaw/knowledge/__init__.py +4 -20
  148. package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
  149. package/python/foxclaw/learning/knowledge_graph.py +4 -9
  150. package/python/foxclaw/learning/models.py +13 -13
  151. package/python/foxclaw/learning/reasoner.py +1 -4
  152. package/python/foxclaw/learning/runtime.py +19 -31
  153. package/python/foxclaw/learning/spaced_repetition.py +2 -7
  154. package/python/foxclaw/mcp_server.py +2 -3
  155. package/python/foxclaw/orchestration/__init__.py +16 -33
  156. package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
  157. package/python/foxclaw/pipeline/stage_meta.py +21 -0
  158. package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
  159. package/python/foxclaw/planning/llm_planner.py +3 -3
  160. package/python/foxclaw/planning/planner.py +6 -11
  161. package/python/foxclaw/search/agent_port.py +7 -11
  162. package/python/foxclaw/search/evidence.py +0 -4
  163. package/python/foxclaw/search/internal_search.py +3 -5
  164. package/python/foxclaw/search/models.py +11 -13
  165. package/python/foxclaw/search/planner.py +3 -3
  166. package/python/foxclaw/search/retriever.py +1 -1
  167. package/python/foxclaw/search/runtime.py +1 -8
  168. package/python/foxclaw/skills/__init__.py +46 -100
  169. package/python/foxclaw/skills/archive.py +111 -0
  170. package/python/foxclaw/skills/fetch.py +226 -0
  171. package/python/foxclaw/skills/hooks_spec.py +21 -25
  172. package/python/foxclaw/skills/install_cmd.py +216 -0
  173. package/python/foxclaw/skills/lifecycle.py +16 -42
  174. package/python/foxclaw/skills/markdown_skill.py +10 -9
  175. package/python/foxclaw/skills/mcp_client.py +5 -11
  176. package/python/foxclaw/skills/mcp_store.py +0 -3
  177. package/python/foxclaw/skills/permission.py +13 -48
  178. package/python/foxclaw/skills/pipeline.py +21 -53
  179. package/python/foxclaw/skills/policy.py +4 -10
  180. package/python/foxclaw/skills/review.py +3 -3
  181. package/python/frontend/deck/auth.js +0 -7
  182. package/python/frontend/deck/chat_model_picker.js +0 -54
  183. package/python/frontend/deck/domain.js +0 -20
  184. package/python/frontend/deck/main.js +0 -144
  185. package/python/frontend/deck/markdown.js +0 -4
  186. package/python/frontend/deck/settings.js +1 -18
  187. package/python/frontend/deck/state.js +0 -1
  188. package/python/frontend/deck/util.js +0 -2
  189. package/python/frontend/deck/visuals.js +0 -22
  190. package/python/frontend/planet/planet.js +18 -201
  191. package/python/frontend/v3/assets/index-JKfHlb-a.css +1 -0
  192. package/python/frontend/v3/assets/index-tN90dpUH.js +34 -0
  193. package/python/frontend/v3/index.html +3 -3
  194. package/python/frontend/widget_runtime.js +0 -43
  195. package/python/frontend/widgets.js +0 -57
  196. package/python/frontend_src/index.html +1 -1
  197. package/python/frontend_src/package-lock.json +2 -2
  198. package/python/frontend_src/package.json +1 -1
  199. package/python/frontend_src/src/App.vue +3 -7
  200. package/python/frontend_src/src/api.js +0 -7
  201. package/python/frontend_src/src/components/ChatDock.vue +27 -39
  202. package/python/frontend_src/src/components/DocViewer.vue +0 -9
  203. package/python/frontend_src/src/components/Inspector.vue +0 -6
  204. package/python/frontend_src/src/components/LeftRail.vue +60 -39
  205. package/python/frontend_src/src/components/MenuPopover.vue +9 -11
  206. package/python/frontend_src/src/components/MessageStream.vue +18 -30
  207. package/python/frontend_src/src/components/ModelPicker.vue +0 -5
  208. package/python/frontend_src/src/components/QuickIngest.vue +0 -6
  209. package/python/frontend_src/src/components/Stage.vue +71 -25
  210. package/python/frontend_src/src/components/ToolMenu.vue +0 -3
  211. package/python/frontend_src/src/components/TopBar.vue +38 -10
  212. package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
  213. package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
  214. package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
  215. package/python/frontend_src/src/components/panels/MemoryPanel.vue +42 -3
  216. package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
  217. package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
  218. package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
  219. package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
  220. package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
  221. package/python/frontend_src/src/lib/fmt.js +0 -4
  222. package/python/frontend_src/src/lib/i18n.js +53 -0
  223. package/python/frontend_src/src/lib/locales/en.js +173 -0
  224. package/python/frontend_src/src/lib/locales/zh.js +174 -0
  225. package/python/frontend_src/src/lib/md.js +0 -14
  226. package/python/frontend_src/src/main.js +2 -0
  227. package/python/frontend_src/src/stores/app.js +17 -32
  228. package/python/frontend_src/src/stores/chat.js +0 -34
  229. package/python/frontend_src/src/stores/model.js +0 -8
  230. package/python/frontend_src/src/stores/panels.js +16 -44
  231. package/python/frontend_src/src/stores/profile.js +31 -0
  232. package/python/frontend_src/src/stores/system.js +25 -0
  233. package/python/frontend_src/vite.config.js +0 -7
  234. package/python/pyproject.toml +17 -3
  235. package/python/requirements.txt +311 -0
  236. package/python/tests/conftest.py +0 -22
  237. package/python/tests/run_all.py +4 -66
  238. package/python/tests/test_agent_lifecycle.py +4 -38
  239. package/python/tests/test_agent_rules.py +81 -0
  240. package/python/tests/test_ahs_p2.py +4 -25
  241. package/python/tests/test_anthropic_tools.py +1 -15
  242. package/python/tests/test_api.py +19 -9
  243. package/python/tests/test_api_auth.py +1 -5
  244. package/python/tests/test_attachments.py +9 -25
  245. package/python/tests/test_audit_chain.py +2 -7
  246. package/python/tests/test_audit_sink.py +0 -6
  247. package/python/tests/test_behavior_spec.py +198 -0
  248. package/python/tests/test_browser.py +2 -6
  249. package/python/tests/test_browser_request_guard.py +0 -4
  250. package/python/tests/test_capabilities.py +0 -6
  251. package/python/tests/test_channel_signing.py +2 -9
  252. package/python/tests/test_channels.py +1 -11
  253. package/python/tests/test_config_layering.py +0 -4
  254. package/python/tests/test_confirm_nonce.py +1 -12
  255. package/python/tests/test_conflict_dismiss.py +143 -0
  256. package/python/tests/test_content_guard.py +177 -0
  257. package/python/tests/test_context_compaction_wiring.py +1 -7
  258. package/python/tests/test_dispatch.py +0 -6
  259. package/python/tests/test_domain_evals.py +11 -80
  260. package/python/tests/test_domain_import.py +0 -6
  261. package/python/tests/test_domain_reasoners.py +0 -14
  262. package/python/tests/test_e2e.py +0 -17
  263. package/python/tests/test_emb_cache.py +7 -17
  264. package/python/tests/test_embedding_singleton.py +1 -3
  265. package/python/tests/test_evals_smoke.py +0 -24
  266. package/python/tests/test_event_spine.py +10 -18
  267. package/python/tests/test_evidence_semantic.py +1 -14
  268. package/python/tests/test_evidence_sse.py +3 -6
  269. package/python/tests/test_file_ingest.py +0 -11
  270. package/python/tests/test_finance.py +0 -9
  271. package/python/tests/test_finance_ledger.py +5 -12
  272. package/python/tests/test_finance_persist.py +8 -12
  273. package/python/tests/test_gateway.py +3 -22
  274. package/python/tests/test_governance.py +5 -52
  275. package/python/tests/test_governance_audit.py +4 -39
  276. package/python/tests/test_governance_middleware.py +1 -7
  277. package/python/tests/test_hkc.py +6 -24
  278. package/python/tests/test_hkc_index_build_manager.py +0 -33
  279. package/python/tests/test_hkc_manifest_reuse.py +4 -27
  280. package/python/tests/test_hkc_search_event_sync.py +3 -54
  281. package/python/tests/test_import_path_guard.py +0 -6
  282. package/python/tests/test_injection_guard.py +18 -36
  283. package/python/tests/test_integration.py +5 -29
  284. package/python/tests/test_itinerary.py +0 -3
  285. package/python/tests/test_kee_semantic.py +0 -5
  286. package/python/tests/test_kernel.py +1 -11
  287. package/python/tests/test_learning.py +3 -12
  288. package/python/tests/test_llm_admin.py +4 -19
  289. package/python/tests/test_llm_planner.py +1 -23
  290. package/python/tests/test_markdown_skills.py +3 -14
  291. package/python/tests/test_markdown_skills_openclaw.py +4 -9
  292. package/python/tests/test_mcp_connectors.py +3 -15
  293. package/python/tests/test_mcp_guardian_discovery.py +2 -6
  294. package/python/tests/test_mcp_no_memory.py +3 -3
  295. package/python/tests/test_mcp_policy_gate.py +0 -1
  296. package/python/tests/test_mcp_server.py +6 -9
  297. package/python/tests/test_memory_recall.py +1 -4
  298. package/python/tests/test_metrics.py +0 -8
  299. package/python/tests/test_multi_chain.py +1 -13
  300. package/python/tests/test_multimodal.py +5 -18
  301. package/python/tests/test_negotiation.py +3 -13
  302. package/python/tests/test_orchestration.py +1 -11
  303. package/python/tests/test_orchestration_decoupled.py +0 -6
  304. package/python/tests/test_p3_p5_hardening.py +3 -17
  305. package/python/tests/test_parallel_dispatch.py +4 -12
  306. package/python/tests/test_pipeline.py +17 -58
  307. package/python/tests/test_plan_visible.py +4 -4
  308. package/python/tests/test_planner.py +0 -7
  309. package/python/tests/test_policy_gate.py +2 -3
  310. package/python/tests/test_profile.py +67 -0
  311. package/python/tests/test_readme_claims.py +163 -0
  312. package/python/tests/test_reasoner.py +2 -17
  313. package/python/tests/test_release_check.py +2 -13
  314. package/python/tests/test_resume_checkpoint.py +7 -15
  315. package/python/tests/test_routing.py +2 -12
  316. package/python/tests/test_scheduler.py +7 -10
  317. package/python/tests/test_search.py +1 -11
  318. package/python/tests/test_secrets.py +4 -10
  319. package/python/tests/test_semantic.py +1 -10
  320. package/python/tests/test_sensitive_write.py +181 -0
  321. package/python/tests/test_sessions.py +3 -4
  322. package/python/tests/test_settings.py +2 -18
  323. package/python/tests/test_shadow_learning.py +3 -43
  324. package/python/tests/test_shared_write.py +0 -7
  325. package/python/tests/test_skill_taint_gate.py +1 -10
  326. package/python/tests/test_skills.py +2 -18
  327. package/python/tests/test_skills_fetch.py +282 -0
  328. package/python/tests/test_stats_and_events.py +2 -5
  329. package/python/tests/test_streaming.py +2 -7
  330. package/python/tests/test_supervisor_pipeline.py +4 -5
  331. package/python/tests/test_supervisor_triage.py +4 -10
  332. package/python/tests/test_taint.py +2 -7
  333. package/python/tests/test_task_stream.py +1 -12
  334. package/python/tests/test_thinking_sse.py +0 -1
  335. package/python/tests/test_tool_center.py +1 -17
  336. package/python/tests/test_tool_parallel.py +2 -2
  337. package/python/tests/test_tool_params.py +1 -5
  338. package/python/tests/test_tool_use.py +0 -13
  339. package/python/tests/test_toplevel_concurrency.py +3 -4
  340. package/python/tests/test_tracing.py +7 -41
  341. package/python/tests/test_truth_crystallizer.py +4 -6
  342. package/python/tests/test_truth_flow.py +2 -7
  343. package/python/tests/test_update_check.py +95 -0
  344. package/python/tests/test_verify_evidence.py +8 -21
  345. package/python/tests/test_version_sync.py +35 -6
  346. package/python/tests/test_web_trigger.py +0 -17
  347. package/python/tools/build_content_lexicon.py +218 -0
  348. package/python/tools/bundle_web.py +98 -0
  349. package/python/tools/check_faiss_type.py +0 -1
  350. package/python/tools/check_log.ps1 +0 -8
  351. package/python/tools/mypy_baseline.txt +12 -4
  352. package/python/tools/mypy_ratchet.py +52 -6
  353. package/python/tools/pack.py +0 -5
  354. package/python/tools/recheck_conflicts.py +156 -0
  355. package/python/tools/release_check.py +24 -15
  356. package/python/tools/restart_foxclaw.ps1 +5 -8
  357. package/python/tools/shared_write_audit.py +1 -15
  358. package/python/tools/size_check.py +1 -1
  359. package/python/tools/strip_comments.py +228 -0
  360. package/python/vendor/hkc/hkc_api/routes/ability.py +31 -0
  361. package/python/vendor/hkc/hkc_api/schemas.py +11 -0
  362. package/python/vendor/hkc/hkc_kee/kee.py +124 -3
  363. package/python/vendor/hkc/hkc_sdk/base.py +22 -0
  364. package/python/vendor/hkc/hkc_sdk/direct_client.py +10 -0
  365. package/python/vendor/hkc/hkc_sdk/http_client.py +19 -0
  366. package/python/foxclaw/api/app.py +0 -123
  367. package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
  368. package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
@@ -0,0 +1,95 @@
1
+ """新版本检查:默认关、版本比较、缓存 TTL、失败静默。
2
+
3
+ 全部离线:``_fetch_latest`` 一律用 monkeypatch 替换,不发真实网络请求。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import time
9
+
10
+ from foxclaw.core import update_check as uc
11
+
12
+
13
+ def _reset() -> None:
14
+ uc._inflight = False
15
+
16
+
17
+ def test_default_disabled(monkeypatch, tmp_path):
18
+ """不设 FOXCLAW_UPDATE_CHECK → 不查、不写缓存、不起线程。"""
19
+ _reset()
20
+ monkeypatch.delenv("FOXCLAW_UPDATE_CHECK", raising=False)
21
+ monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
22
+
23
+ assert uc.status()["enabled"] is False
24
+ uc.maybe_refresh()
25
+ assert uc._inflight is False
26
+ assert not (tmp_path / "update_check.json").exists()
27
+
28
+
29
+ def test_version_compare():
30
+ assert uc.parse_version("0.1.10") == (0, 1, 10)
31
+ assert uc.parse_version("0.2.0rc1") == (0, 2, 0)
32
+ assert uc.parse_version("") == ()
33
+ assert uc.is_newer("0.1.10", "0.1.2") is True
34
+ assert uc.is_newer("0.1.2", "0.1.2") is False
35
+ assert uc.is_newer("0.1.2", "0.1.10") is False
36
+
37
+
38
+ def test_refresh_writes_cache(monkeypatch, tmp_path):
39
+ _reset()
40
+ monkeypatch.setenv("FOXCLAW_UPDATE_CHECK", "1")
41
+ monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
42
+ monkeypatch.setattr(uc, "_fetch_latest", lambda: "9.9.9")
43
+
44
+ uc._refresh()
45
+
46
+ st = uc.status()
47
+ assert st["latest"] == "9.9.9"
48
+ assert st["update_available"] is True
49
+ assert st["error"] is None
50
+ assert (tmp_path / "update_check.json").exists()
51
+
52
+
53
+ def test_fresh_cache_skips_refresh(monkeypatch, tmp_path):
54
+ """24h 内不重复查 PyPI。"""
55
+ _reset()
56
+ monkeypatch.setenv("FOXCLAW_UPDATE_CHECK", "1")
57
+ monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
58
+ uc._write_cache({"latest": "9.9.9", "checked_at": time.time(), "current": "0.1.2"})
59
+
60
+ calls: list[int] = []
61
+ monkeypatch.setattr(uc, "_fetch_latest", lambda: calls.append(1) or "9.9.9")
62
+ uc.maybe_refresh()
63
+ time.sleep(0.05)
64
+
65
+ assert calls == []
66
+ assert uc._inflight is False
67
+
68
+
69
+ def test_failure_is_silent(monkeypatch, tmp_path):
70
+ """网络失败不抛异常,只记 error;update_available 仍为 False。"""
71
+ _reset()
72
+ monkeypatch.setenv("FOXCLAW_UPDATE_CHECK", "1")
73
+ monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
74
+
75
+ def boom():
76
+ raise OSError("offline")
77
+
78
+ monkeypatch.setattr(uc, "_fetch_latest", boom)
79
+ uc._refresh()
80
+
81
+ st = uc.status()
82
+ assert st["update_available"] is False
83
+ assert "offline" in (st["error"] or "")
84
+
85
+
86
+ def test_disabled_never_reports_update(monkeypatch, tmp_path):
87
+ """缓存里就算有更新的版本,没启用也不提示(默认不外呼,也不拿旧缓存骚扰)。"""
88
+ _reset()
89
+ monkeypatch.delenv("FOXCLAW_UPDATE_CHECK", raising=False)
90
+ monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
91
+ uc._write_cache({"latest": "99.0.0", "checked_at": time.time(), "current": "0.1.2"})
92
+
93
+ st = uc.status()
94
+ assert st["enabled"] is False
95
+ assert st["update_available"] is False
@@ -38,8 +38,6 @@ def _agent():
38
38
  return a
39
39
 
40
40
 
41
- # ── ① 失败策略表 ─────────────────────────────────────────────
42
-
43
41
  @pytest.mark.parametrize("text,kind", [
44
42
  (TIMEOUT_TEXT, policy.TIMEOUT),
45
43
  ("(工具 shell 已被策略禁用)", policy.DENIED),
@@ -47,8 +45,8 @@ def _agent():
47
45
  ("(入参不合法:缺少必填 query)", policy.INVALID_ARGS),
48
46
  ("(联网搜索未返回结果)", policy.EMPTY),
49
47
  ("(工具 web_search 执行异常:boom)", policy.EXCEPTION),
50
- ("中国未来十年的产业趋势如何", policy.OK), # 正文含"未"不能误判
51
- ("", policy.UNKNOWN), # 空结果不能当成功
48
+ ("中国未来十年的产业趋势如何", policy.OK),
49
+ ("", policy.UNKNOWN),
52
50
  ("一切正常", policy.OK),
53
51
  ])
54
52
  def test_classify(text, kind) -> None:
@@ -58,10 +56,8 @@ def test_classify(text, kind) -> None:
58
56
  def test_retry_only_for_read_only() -> None:
59
57
  read = policy.decide(TIMEOUT_TEXT, capability={"read_only": True}, attempt=1)
60
58
  assert read.action == policy.RETRY
61
- # 第二次还失败 → 不再重试,交人
62
59
  assert policy.decide(TIMEOUT_TEXT, capability={"read_only": True}, attempt=2).action \
63
60
  == policy.HAND_TO_HUMAN
64
- # 有副作用:第一次就不许自动重试
65
61
  write = policy.decide(TIMEOUT_TEXT, capability={"read_only": False}, attempt=1)
66
62
  assert write.action == policy.HAND_TO_HUMAN
67
63
  assert "重复执行" in write.hint
@@ -70,7 +66,6 @@ def test_retry_only_for_read_only() -> None:
70
66
  def test_decide_never_optimistic() -> None:
71
67
  assert policy.decide("(联网搜索未配置)").action == policy.DEGRADE
72
68
  assert policy.decide("(工具 x 已被策略禁用)").action == policy.STOP
73
- # 形如失败但聊不归类 → 保守交人,不乐观当成功
74
69
  assert policy.decide("(看不懂的回注").kind == policy.UNKNOWN
75
70
  assert policy.decide("(看不懂的回注").action == policy.HAND_TO_HUMAN
76
71
 
@@ -84,8 +79,6 @@ def test_annotate_leaves_success_untouched() -> None:
84
79
  assert "[执行策略]" in out
85
80
 
86
81
 
87
- # ── ② 执行后核验 ─────────────────────────────────────────────
88
-
89
82
  def test_failure_is_failed_not_verified() -> None:
90
83
  v = vf.verify("web_search", {}, TIMEOUT_TEXT, {"read_only": True})
91
84
  assert v.status == vf.FAILED
@@ -110,7 +103,6 @@ def test_side_effect_without_postcondition_is_unverified() -> None:
110
103
  v = vf.verify("send_mail", {}, "已发送", {"read_only": False})
111
104
  assert v.status == vf.UNVERIFIED
112
105
  assert "后置条件" in v.reason
113
- # 只读工具拿到内容就算这一步发生过(内容可信度归污点层)
114
106
  assert vf.verify("recall_memory", {}, "【召回的相关记忆】\n- a", {"read_only": True}).status \
115
107
  == vf.VERIFIED
116
108
 
@@ -144,14 +136,12 @@ def test_note_for_model() -> None:
144
136
  ok = vf.Verification(vf.VERIFIED)
145
137
  assert vf.note_for_model(ok) == ""
146
138
  un_read = vf.Verification(vf.UNVERIFIED, "无来源")
147
- assert vf.note_for_model(un_read, read_only=True) == "" # 只读不训话,免得污染上下文
139
+ assert vf.note_for_model(un_read, read_only=True) == ""
148
140
  assert "不要断言" in vf.note_for_model(un_read, read_only=False)
149
141
  bad = vf.Verification(vf.FAILED, "失败类型:timeout")
150
142
  assert "不要向用户声称" in vf.note_for_model(bad)
151
143
 
152
144
 
153
- # ── ③ 接进工具轮 ─────────────────────────────────────────────
154
-
155
145
  def test_retries_read_only_once_then_gives_up(monkeypatch) -> None:
156
146
  agent = _agent()
157
147
  batches: list = []
@@ -164,9 +154,9 @@ def test_retries_read_only_once_then_gives_up(monkeypatch) -> None:
164
154
  monkeypatch.setattr(core_tools, "tool_capability", lambda n: {"read_only": True})
165
155
  out = tool_exec._run_tool_calls(agent, [{"name": "web_search", "args": {"q": "x"}}])
166
156
 
167
- assert len(batches) == 2 # 第一次 + 自动重试一次
157
+ assert len(batches) == 2
168
158
  assert "[执行策略]" in out[0] and "不要" in out[0]
169
- assert agent._recovery.steps == [] # 失败的步不许存进断点
159
+ assert agent._recovery.steps == []
170
160
  ev = agent._evidence_list
171
161
  assert ev and ev[0]["status"] == vf.FAILED
172
162
 
@@ -178,7 +168,7 @@ def test_side_effect_failure_is_not_retried(monkeypatch) -> None:
178
168
  lambda a, calls: (batches.append(len(calls)), [TIMEOUT_TEXT] * len(calls))[1])
179
169
  monkeypatch.setattr(core_tools, "tool_capability", lambda n: {"read_only": False})
180
170
  out = tool_exec._run_tool_calls(agent, [{"name": "send_mail", "args": {}}])
181
- assert batches == [1] # 一次,不重试
171
+ assert batches == [1]
182
172
  assert "重复执行" in out[0]
183
173
 
184
174
 
@@ -189,8 +179,8 @@ def test_success_text_untouched_and_evidence_recorded(monkeypatch) -> None:
189
179
  monkeypatch.setattr(core_tools, "tool_capability", lambda n: {"read_only": True})
190
180
  out = tool_exec._run_tool_calls(agent, [{"name": "web_search", "args": {"q": "x"}}])
191
181
 
192
- assert out[0] == "【联网搜索结果】结论 https://example.com/a" # 成功文本不被改
193
- assert len(agent._recovery.steps) == 1 # 成功的步可复用
182
+ assert out[0] == "【联网搜索结果】结论 https://example.com/a"
183
+ assert len(agent._recovery.steps) == 1
194
184
  ev = agent._evidence_list[0]
195
185
  assert ev["status"] == vf.VERIFIED
196
186
  assert ev["evidence"][0]["ref"] == "https://example.com/a"
@@ -206,8 +196,6 @@ def test_unverified_side_effect_tells_model_not_to_claim_done(monkeypatch) -> No
206
196
  assert agent._evidence_list[0]["status"] == vf.UNVERIFIED
207
197
 
208
198
 
209
- # ── ④ 证据不串台 ─────────────────────────────────────────────
210
-
211
199
  def test_evidence_is_per_agent() -> None:
212
200
  from foxclaw.agents.mixins import _context
213
201
 
@@ -216,7 +204,6 @@ def test_evidence_is_per_agent() -> None:
216
204
  _context._evidence_reset(b)
217
205
  _context._evidence_add(a, {"tool": "x", "status": "verified"})
218
206
  assert _context._evidence_take(a) and not _context._evidence_take(b)
219
- # take 之后清空:下一轮不会重复上报上一轮的证据
220
207
  assert _context._evidence_take(a) == []
221
208
 
222
209
 
@@ -1,15 +1,16 @@
1
1
  """版本号单一来源校验。
2
2
 
3
- 版本号目前硬编码在四个地方,任何一处漏改都会造成「本地是 0.2.0、npm 上
3
+ 版本号目前硬编码在五个地方,任何一处漏改都会造成「本地是 0.2.0、npm 上
4
4
  还是 0.1.0」这类对外不一致(`npm/index.js` 的 version 是发布物里用户
5
5
  `import` 直接读到的值,最容易漏):
6
6
 
7
- - ``pyproject.toml`` 的 ``project.version`` —— PyPI 包版本
8
- - ``foxclaw/__init__.py`` 的 ``__version__`` —— 运行时 ``foxclaw --version``
9
- - ``npm/package.json`` 的 ``version`` —— npm 包版本
10
- - ``npm/index.js`` 的 ``export const version`` —— npm 包对外导出的版本
7
+ - ``pyproject.toml`` 的 ``project.version`` —— PyPI 包版本
8
+ - ``foxclaw/__init__.py`` 的 ``__version__`` —— 运行时 ``foxclaw --version``
9
+ - ``npm/package.json`` 的 ``version`` —— npm 包版本
10
+ - ``npm/index.js`` 的 ``export const version`` —— npm 包对外导出的版本
11
+ - ``frontend_src/package.json`` 的 ``version`` —— 前端构建工程版本
11
12
 
12
- 本测试只做「四处必须相等」,不校验具体数值,发版时改哪一处都不用动它。
13
+ 本测试只做「五处必须相等」,不校验具体数值,发版时改哪一处都不用动它。
13
14
  """
14
15
 
15
16
  from __future__ import annotations
@@ -57,6 +58,17 @@ def test_npm_index_export_version_agrees() -> None:
57
58
  assert match.group(1) == _npm_package()["version"]
58
59
 
59
60
 
61
+ def test_frontend_package_version_agrees() -> None:
62
+ """frontend_src/package.json 与 PyPI 侧版本一致。
63
+
64
+ 这个工程是 ``private`` 的构建工程,不发布,版本号曾长期停在 3.0.0
65
+ (Vue3 渲染层代号),与后端 0.1.2 对不上,容易误读成产品版本。
66
+ """
67
+ data = json.loads(
68
+ (REPO_ROOT / "frontend_src" / "package.json").read_text(encoding="utf-8"))
69
+ assert data["version"] == _project_version()
70
+
71
+
60
72
  def test_package_names_are_foxclaw() -> None:
61
73
  """PyPI 与 npm 两侧包名必须同名,且为 foxclaw。"""
62
74
  assert _project_name() == "foxclaw"
@@ -69,3 +81,20 @@ def test_npm_index_export_name_agrees() -> None:
69
81
  match = re.search(r'export\s+const\s+name\s*=\s*["\']([^"\']+)["\']', source)
70
82
  assert match, "npm/index.js 里找不到 `export const name = \"...\"`"
71
83
  assert match.group(1) == _npm_package()["name"]
84
+
85
+
86
+ def test_frontend_does_not_hardcode_version() -> None:
87
+ """顶部栏版本号必须来自后端,不能在 Vue 里写死。
88
+
89
+ 2026-11-10:左上角硬编码 ``v3.0``,而后端已发到 0.1.2,用户看到的版本
90
+ 与仓库/PyPI 对不上。取值链路是 ``stores/system.js`` 请求
91
+ ``/api/system/info``,TopBar 只读 ``system.version``。
92
+ """
93
+ topbar = (REPO_ROOT / "frontend_src" / "src" / "components" / "TopBar.vue").read_text(
94
+ encoding="utf-8")
95
+ assert not re.search(r"\bv\d+\.\d+", topbar), "TopBar.vue 里出现了硬编码版本号"
96
+ assert "system.version" in topbar, "TopBar.vue 应从 system store 读 version"
97
+
98
+ store = (REPO_ROOT / "frontend_src" / "src" / "stores" / "system.js").read_text(
99
+ encoding="utf-8")
100
+ assert "/system/info" in store, "system store 应从 /api/system/info 取 version"
@@ -11,8 +11,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
11
11
  import pytest
12
12
 
13
13
  def test_web_trigger():
14
- # ISOLATION_GUARD:本用例依赖 import 时固化的模块级单例(网关/限流器/路由配置按 env 在 import 时定型),单进程全量跑会被前序用例污染 →
15
- # 假红。全量请用 tests/run_all.py(每文件独立子进程)。
16
14
  if "foxclaw.core.web_search" in sys.modules:
17
15
  pytest.skip("需独立进程运行(模块级单例已固化),请用 tests/run_all.py")
18
16
  from foxclaw.core.web_search import (needs_search, TOPIC_TRIGGERS,
@@ -20,51 +18,39 @@ def test_web_trigger():
20
18
 
21
19
  print("=" * 60); print("联网触发判定验证"); print("=" * 60)
22
20
 
23
- # [1] 个人数据 → 绝不联网(答案在本地领域内核里,不在网上)
24
21
  personal = ["分析一下本月支出", "本月餐饮花了多少", "今天的行程安排",
25
22
  "本周要复习什么知识点", "账户余额还剩多少", "这个月消费记录"]
26
23
  for q in personal:
27
24
  assert needs_search(q) is False, f"个人数据不该联网: {q}"
28
25
  print(f"[1] 个人数据不联网({len(personal)} 例,含「本月支出」这类原 bug 用例)OK")
29
26
 
30
- # [2] 个人数据 + 话题词 → 仍不联网(否决优先级最高)
31
- # 「本月支出多少钱」含话题词「多少钱」,但问的是自己的账 → 必须否决
32
27
  assert needs_search("本月支出多少钱") is False, "个人数据否决应压过话题触发"
33
28
  assert needs_search("我的预算还剩多少钱") is False
34
29
  print("[2] 个人数据否决压过话题触发(「本月支出多少钱」不联网)OK")
35
30
 
36
- # [3] 外部世界话题 → 正常联网(不能因为修 bug 把真需求也砍了)
37
31
  external = ["今天天气怎么样", "最新的AI新闻", "iPhone现在多少钱",
38
32
  "特斯拉股价", "python最新版本", "美元汇率"]
39
33
  for q in external:
40
34
  assert needs_search(q) is True, f"外部话题应联网: {q}"
41
35
  print(f"[3] 外部话题正常联网({len(external)} 例)OK")
42
36
 
43
- # [4] 「我的」不算个人标记 —— 「我的股票现在多少钱」是真要联网的
44
37
  assert needs_search("我的股票现在多少钱") is True, "「我的」过宽会误杀真实联网需求"
45
38
  print("[4] 「我的股票现在多少钱」仍联网(个人标记不能放「我的」这类过宽词)OK")
46
39
 
47
- # [5] 纯时间词不触发(本 bug 的核心)
48
40
  for q in ["现在几点", "最近怎么样", "今天", "本月"]:
49
41
  assert needs_search(q) is False, f"纯时间词不该单独触发: {q}"
50
42
  assert not (set(TIME_WORDS) & set(TOPIC_TRIGGERS)), "时间词不得混入话题触发表(否则 bug 复发)"
51
43
  print(f"[5] 纯时间词不单独触发({len(TIME_WORDS)} 个时间词与话题表零交集)OK")
52
44
 
53
- # [6] 闲聊/编码 → 不联网
54
45
  for q in ["你好", "帮我写个函数", ""]:
55
46
  assert needs_search(q) is False, q
56
47
  print("[6] 闲聊/编码/空串 不联网 OK")
57
48
 
58
- # [7] 英文词边界仍生效(旧修复不能退化:now⊄knowledge, current⊄concurrent)
59
49
  assert needs_search("knowledge base") is False, "now 不该子串命中 knowledge"
60
50
  assert needs_search("concurrent tasks") is False, "current 不该子串命中 concurrent"
61
51
  assert needs_search("latest news") is True
62
52
  print("[7] 英文词边界不退化(knowledge/concurrent 不误命中)OK")
63
53
 
64
- # [8] 【防复发·跨路径】个人数据保护必须是**单一真源**,所有"要不要发到外部"的路径都得遵守。
65
- # 真实教训:这条保护原先只写在 needs_search 里;技能派发默认开启后,
66
- # 「查一下最新的本月支出」经**技能路由**照样把私人财务问题发去 external 的 web_search
67
- # —— 同一个 bug 从没设防的旁路回来了,还多了数据外泄。
68
54
  from foxclaw.core.web_search import is_personal_query
69
55
  from foxclaw.pipeline import stage_route
70
56
  from foxclaw.pipeline.cognitive_pipeline import PipelineContext
@@ -81,14 +67,11 @@ def test_web_trigger():
81
67
  stage_route(ctx)
82
68
  return ctx.route["target"], ctx.route.get("skill_id")
83
69
 
84
- # 私人数据 + 足够高的置信 → 仍不得路由到 **external** 技能(否则就是外泄)
85
70
  for q in ["查一下最新的本月支出", "查一下最新的预算结余", "今天的行程 最新 查一下"]:
86
71
  t, sid = _route_target(q)
87
72
  assert t == "self", f"私人数据不得自动发给外部技能: {q} → {t}/{sid}"
88
- # 但真实的外部问题照常走技能(不能因为修 bug 把功能砍了)
89
73
  t, sid = _route_target("查一下最新新闻")
90
74
  assert (t, sid) == ("skill", "web_search"), (t, sid)
91
- # 本地只读技能不受影响
92
75
  t, sid = _route_target("帮我计算 (12+8)*5")
93
76
  assert (t, sid) == ("skill", "calculator"), (t, sid)
94
77
  print("[8] 跨路径防复发:技能路由也遵守个人数据保护(不外泄)+ 不误伤真实外部需求 OK")
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python
2
+ """把外部敏感词库(如 houbb/sensitive-word)转成 ContentGuard 可挂载的词表格式。
3
+
4
+ 为什么要转换而不是直接挂
5
+ ──────────────────────
6
+ `ContentGuard` 载入时会对每个词做一次 `compact()`(去空白标点、全半角归一、小写),
7
+ 但那只保证「扫描时能对上」,不保证词表本身干净:同一批词里 `敏感词`、`敏 感 词`、
8
+ `SENSITIVE` 会各占一条,白白撑大自动机。转换期先统一形态,词表体积和重复率
9
+ 都能压下来。
10
+
11
+ 输出格式就是 `FOXCLAW_CONTENT_LEXICON` 认的那一种:一行一条 `词<TAB>类别`,`#` 开头为
12
+ 注释。类别会原样出现在命中结果里,便于审计归类。
13
+
14
+ 词库形态
15
+ ────────
16
+ 两种都认,不用改文件:
17
+ * 单文件纯词列表(houbb 主词库):文件名不带中文时用 `--category`(默认 platform)。
18
+ * 一个文件一个类别(`政治类.txt` / `色情类.txt` / ...):中文文件名直接当类别名。
19
+
20
+ 哪些类别挂上去由你决定。脚本默认照单全收 —— 排除政治 / 色情 / 暴力这些大类是部署决策,
21
+ 不该由转换工具替你拍板。用 `--exclude` / `--include`(支持 `通配*`)自己取舍。
22
+
23
+ 用法
24
+ ────
25
+ # 主词库,单一类别
26
+ python tools/build_content_lexicon.py ../houbb/sensitive_word_dict.txt
27
+
28
+ # 分类词库,只留自己需要的
29
+ python tools/build_content_lexicon.py dict/*.txt --exclude 政治类,色情类 --exclude "暴力*"
30
+
31
+ # 指定类别名与输出路径,转换后自动验证能否载入
32
+ python tools/build_content_lexicon.py dict/垃圾广告类.txt --category ads -o ads.txt
33
+
34
+ # 挂上去用
35
+ # FOXCLAW_CONTENT_LEXICON=content_lexicon.txt
36
+
37
+ 挂上去之前先跑几天 audit
38
+ ────────────────────────
39
+ 六万条量级的词表里必然有大量双字词,直接挂 enforce 会明显影响正常对话。建议先
40
+ 置 FOXCLAW_CONTENT_GUARD_MODE=audit 观察一段时间的命中记录,确认误伤可接受再转
41
+ enforce —— 这层是兜底,不是让产品变得不好用。
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import fnmatch
47
+ import importlib.util
48
+ import sys
49
+ from collections import Counter, OrderedDict
50
+ from pathlib import Path
51
+
52
+ ROOT = Path(__file__).resolve().parent.parent
53
+
54
+
55
+ def _load_guard_module():
56
+ """按文件路径载入 core/content_guard.py —— 它只依赖标准库,不必触发包的 __init__。"""
57
+ path = ROOT / "foxclaw" / "core" / "content_guard.py"
58
+ spec = importlib.util.spec_from_file_location("_foxclaw_content_guard", path)
59
+ if spec is None or spec.loader is None:
60
+ raise SystemExit(f"找不到 {path}")
61
+ mod = importlib.util.module_from_spec(spec)
62
+ sys.modules["_foxclaw_content_guard"] = mod
63
+ spec.loader.exec_module(mod)
64
+ return mod
65
+
66
+
67
+ def _read_text(path: Path, encoding: str) -> str:
68
+ data = path.read_bytes()
69
+ for enc in ([encoding] if encoding != "auto" else ["utf-8-sig", "gb18030"]):
70
+ try:
71
+ return data.decode(enc)
72
+ except UnicodeDecodeError:
73
+ continue
74
+ raise SystemExit(f"{path}: 无法解码,用 --encoding 指定(试过 utf-8-sig, gb18030)")
75
+
76
+
77
+ def _category_of(path: Path, fallback: str) -> str:
78
+ stem = path.stem.strip()
79
+ if any("\u4e00" <= ch <= "\u9fff" for ch in stem):
80
+ return _clean_category(stem)
81
+ return _clean_category(fallback)
82
+
83
+
84
+ def _clean_category(name: str) -> str:
85
+ # TAB 是词与类别的分隔符,不能出现在类别里;顺手压掉换行等控制字符。
86
+ s = " ".join(str(name or "").split())
87
+ return s.replace("\t", "/") or "platform"
88
+
89
+
90
+ def _match_any(cat: str, patterns: list[str]) -> bool:
91
+ return any(fnmatch.fnmatchcase(cat, p) for p in patterns)
92
+
93
+
94
+ def _collect(paths: list[Path], args) -> tuple[OrderedDict, Counter]:
95
+ """返回 (词 -> [类别...], 统计)。词已 compact,类别按出现顺序去重。"""
96
+ words: OrderedDict[str, list[str]] = OrderedDict()
97
+ stats = Counter()
98
+ for p in paths:
99
+ text = _read_text(p, args.encoding)
100
+ default_cat = _category_of(p, args.category)
101
+ for line in text.splitlines():
102
+ s = line.strip()
103
+ if not s or s.startswith("#"):
104
+ continue
105
+ stats["lines"] += 1
106
+ word, _, cat = s.partition("\t")
107
+ cat = _clean_category(cat) if cat.strip() else default_cat
108
+ if args.include and not _match_any(cat, args.include):
109
+ stats["skipped_by_include"] += 1
110
+ continue
111
+ if args.exclude and _match_any(cat, args.exclude):
112
+ stats["skipped_by_exclude"] += 1
113
+ continue
114
+ word = args.compact(word)
115
+ if len(word) < args.min_len:
116
+ stats["skipped_too_short"] += 1
117
+ continue
118
+ if args.max_len and len(word) > args.max_len:
119
+ stats["skipped_too_long"] += 1
120
+ continue
121
+ cats = words.setdefault(word, [])
122
+ if cat not in cats:
123
+ if cats:
124
+ stats["merged"] += 1
125
+ cats.append(cat)
126
+ else:
127
+ stats["duplicated"] += 1
128
+ return words, stats
129
+
130
+
131
+ def _write_lexicon(path: Path, words: OrderedDict, sources: list[Path], stats: Counter) -> None:
132
+ counts = Counter(c for cats in words.values() for c in cats)
133
+ header = [
134
+ "# Foxclaw 内容词表 —— 由 tools/build_content_lexicon.py 生成,勿手改",
135
+ "# 源: " + ", ".join(p.name for p in sources),
136
+ "# 条数: %d" % len(words),
137
+ "# 类别: " + ", ".join(f"{c} {n}" for c, n in counts.most_common()),
138
+ "",
139
+ ]
140
+ lines = sorted((f"{w}\t{'/'.join(cats)}" for w, cats in words.items()), key=str.casefold)
141
+ path.parent.mkdir(parents=True, exist_ok=True)
142
+ path.write_text("\n".join(header + lines) + "\n", encoding="utf-8", newline="\n")
143
+
144
+
145
+ def _verify(path: Path, guard_mod, sample: int) -> bool:
146
+ guard = guard_mod.ContentGuard(mode="enforce", lexicon_paths=[str(path)])
147
+ if not guard.lexicon_size:
148
+ return False
149
+ probe = [ln.split("\t")[0] for ln in path.read_text(encoding="utf-8").splitlines()
150
+ if ln and not ln.startswith("#")][:max(1, sample)]
151
+ hits = sum(1 for w in probe if guard.scan(w))
152
+ print(f"验证: ContentGuard 载入 {guard.lexicon_size} 条, 抽样 {hits}/{len(probe)} 命中")
153
+ return hits == len(probe)
154
+
155
+
156
+ def main(argv: list[str] | None = None) -> int:
157
+ ap = argparse.ArgumentParser(
158
+ description="把外部敏感词库转成 FOXCLAW_CONTENT_LEXICON 可用的词表格式",
159
+ formatter_class=argparse.RawDescriptionHelpFormatter,
160
+ epilog="类别筛选支持通配符,例如 --exclude '政治*'。",
161
+ )
162
+ ap.add_argument("inputs", nargs="+", type=Path, help="词库文件,可多个")
163
+ ap.add_argument("-o", "--output", type=Path, default=Path("content_lexicon.txt"),
164
+ help="输出词表路径(默认 content_lexicon.txt)")
165
+ ap.add_argument("--category", default="platform",
166
+ help="文件名不带中文时使用的类别名(默认 platform)")
167
+ ap.add_argument("--include", action="append", default=[],
168
+ help="只导出匹配的类别,逗号分隔,可重复,支持通配")
169
+ ap.add_argument("--exclude", action="append", default=[],
170
+ help="排除匹配的类别,逗号分隔,可重复,支持通配")
171
+ ap.add_argument("--encoding", default="auto", help="auto / utf-8 / gb18030 等,默认 auto")
172
+ ap.add_argument("--min-len", type=int, default=2,
173
+ help="短于该长度的词丢弃(默认 2,与加载器一致)")
174
+ ap.add_argument("--max-len", type=int, default=0,
175
+ help="长于该长度的词丢弃(默认 0 = 不限)")
176
+ ap.add_argument("--sample", type=int, default=5, help="转换后抽样验证条数(默认 5)")
177
+ ap.add_argument("--no-verify", action="store_true", help="跳过载入验证")
178
+ args = ap.parse_args(argv)
179
+
180
+ def _split(values: list[str]) -> list[str]:
181
+ out: list[str] = []
182
+ for v in values:
183
+ out.extend(x.strip() for x in v.split(",") if x.strip())
184
+ return out
185
+
186
+ args.include = _split(args.include)
187
+ args.exclude = _split(args.exclude)
188
+
189
+ missing = [str(p) for p in args.inputs if not p.is_file()]
190
+ if missing:
191
+ raise SystemExit("找不到词库文件: " + ", ".join(missing))
192
+
193
+ guard_mod = _load_guard_module()
194
+ args.compact = guard_mod.compact
195
+
196
+ words, stats = _collect(args.inputs, args)
197
+ if not words:
198
+ raise SystemExit("没有可导出的词 —— 检查类别筛选或 --min-len")
199
+
200
+ _write_lexicon(args.output, words, args.inputs, stats)
201
+
202
+ counts = Counter(c for cats in words.values() for c in cats)
203
+ print(f"读取 {len(args.inputs)} 个文件, {stats['lines']} 行")
204
+ print("过滤: 类别排除 %d / 不在 include %d / 过短 %d / 过长 %d / 重复 %d / 跨类别归并 %d"
205
+ % (stats["skipped_by_exclude"], stats["skipped_by_include"],
206
+ stats["skipped_too_short"], stats["skipped_too_long"],
207
+ stats["duplicated"], stats["merged"]))
208
+ print(f"输出 {len(words)} 条 → {args.output}")
209
+ print("类别: " + ", ".join(f"{c} {n}" for c, n in counts.most_common()))
210
+
211
+ if not args.no_verify and not _verify(args.output, guard_mod, args.sample):
212
+ print("警告: 载入验证未通过,词表可能不可用", file=sys.stderr)
213
+ return 1
214
+ return 0
215
+
216
+
217
+ if __name__ == "__main__":
218
+ raise SystemExit(main())
@@ -0,0 +1,98 @@
1
+ """把仓库根的 frontend/ 复制进包内 foxclaw/web/,让 wheel/sdist 带上 Web UI。
2
+
3
+ 为什么要复制
4
+ ------------
5
+ `frontend/` 在仓库根、不属于任何 Python 包,`packages.find` 只收
6
+ `foxclaw*` / `hmr*` / `hkc_*`,所以它一直进不了 wheel:`pip install` 之后
7
+ `foxclaw serve` 首页 404,只有 API/CLI。这里在构建前把同一份产物搬进
8
+ `foxclaw/web/`,再由 pyproject 的 `[tool.setuptools.package-data]` 带进 wheel。
9
+ 产物已 gitignore,不入版本库(源头仍是 `frontend/`)。
10
+
11
+ 用法
12
+ ----
13
+ python tools/bundle_web.py # 复制(幂等:先清空 foxclaw/web)
14
+ python tools/bundle_web.py --check # 只校验是否缺失/过期,不复制
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import shutil
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ SRC = ROOT / "frontend"
26
+ DST = ROOT / "foxclaw" / "web"
27
+
28
+ # 与 npm/build.cjs 的跳过规则对齐:依赖、缓存、日志不进包
29
+ SKIP_DIRS = {"node_modules", ".vite", "__pycache__", ".git"}
30
+ SKIP_SUFFIX = {".log", ".err", ".map"}
31
+
32
+ INIT = '''"""Web UI 静态资源 —— 构建期由 tools/bundle_web.py 从仓库根 frontend/ 复制。
33
+
34
+ 不要手改本目录:改动落在 frontend/(或改 frontend_src/ 后重新构建),再跑
35
+ `python tools/bundle_web.py`。本目录已 gitignore,不在版本库里。
36
+ """
37
+ '''
38
+
39
+
40
+ def iter_files(root: Path):
41
+ """按相对路径列出要复制的文件(已排除依赖/缓存/日志)。"""
42
+ for p in sorted(root.rglob("*")):
43
+ if not p.is_file():
44
+ continue
45
+ rel = p.relative_to(root)
46
+ if any(part in SKIP_DIRS for part in rel.parts):
47
+ continue
48
+ if p.suffix in SKIP_SUFFIX:
49
+ continue
50
+ yield rel
51
+
52
+
53
+ def bundle() -> int:
54
+ if not SRC.is_dir():
55
+ print(f"[bundle_web] 找不到源目录 {SRC}", file=sys.stderr)
56
+ return 1
57
+ if DST.exists():
58
+ shutil.rmtree(DST)
59
+ DST.mkdir(parents=True)
60
+ (DST / "__init__.py").write_text(INIT, encoding="utf-8")
61
+ count = 0
62
+ total = 0
63
+ for rel in iter_files(SRC):
64
+ target = DST / rel
65
+ target.parent.mkdir(parents=True, exist_ok=True)
66
+ shutil.copy2(SRC / rel, target)
67
+ count += 1
68
+ total += target.stat().st_size
69
+ print(f"[bundle_web] {count} files / {total / 1024:.0f} KB -> foxclaw/web/")
70
+ if not (DST / "v3" / "index.html").is_file():
71
+ print("[bundle_web] 提示:没有 v3/index.html,首页会退回旧 deck_v2",
72
+ file=sys.stderr)
73
+ return 0
74
+
75
+
76
+ def check() -> int:
77
+ if not DST.is_dir():
78
+ print("[bundle_web] foxclaw/web/ 不存在,构建前先跑 python tools/bundle_web.py",
79
+ file=sys.stderr)
80
+ return 1
81
+ missing = [str(r) for r in iter_files(SRC) if not (DST / r).is_file()]
82
+ if missing:
83
+ print(f"[bundle_web] foxclaw/web/ 已过期({len(missing)} 个文件缺失,"
84
+ f"如 {missing[0]}),先跑 python tools/bundle_web.py", file=sys.stderr)
85
+ return 1
86
+ print("[bundle_web] foxclaw/web/ 与 frontend/ 一致")
87
+ return 0
88
+
89
+
90
+ def main(argv=None) -> int:
91
+ ap = argparse.ArgumentParser(description="把 frontend/ 复制进包内 foxclaw/web/")
92
+ ap.add_argument("--check", action="store_true", help="只校验,不复制")
93
+ args = ap.parse_args(argv)
94
+ return check() if args.check else bundle()
95
+
96
+
97
+ if __name__ == "__main__":
98
+ raise SystemExit(main())