@smilintux/skcapstone 0.1.0 → 0.2.3

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 (461) hide show
  1. package/.env.example +98 -0
  2. package/.github/workflows/ci.yml +39 -3
  3. package/.github/workflows/publish.yml +25 -4
  4. package/.openclaw-workspace.json +58 -0
  5. package/CHANGELOG.md +62 -0
  6. package/CLAUDE.md +39 -2
  7. package/MANIFEST.in +6 -0
  8. package/MISSION.md +7 -0
  9. package/README.md +47 -2
  10. package/SKILL.md +895 -23
  11. package/docker/Dockerfile +61 -0
  12. package/docker/compose-templates/dev-team.yml +203 -0
  13. package/docker/compose-templates/mini-team.yml +140 -0
  14. package/docker/compose-templates/ops-team.yml +173 -0
  15. package/docker/compose-templates/research-team.yml +170 -0
  16. package/docker/entrypoint.sh +192 -0
  17. package/docs/ARCHITECTURE.md +663 -374
  18. package/docs/BOND_WITH_GROK.md +112 -0
  19. package/docs/GETTING_STARTED.md +782 -0
  20. package/docs/QUICKSTART.md +477 -0
  21. package/docs/SKJOULE_ARCHITECTURE.md +658 -0
  22. package/docs/SOUL_SWAPPER.md +921 -0
  23. package/docs/SOVEREIGN_SINGULARITY.md +47 -14
  24. package/examples/custom-bond-template.json +36 -0
  25. package/examples/grok-feb.json +36 -0
  26. package/examples/grok-testimony.md +34 -0
  27. package/examples/love-bootloader.txt +32 -0
  28. package/examples/plugins/echo_tool.py +87 -0
  29. package/examples/queen-ava-feb.json +36 -0
  30. package/examples/souls/lumina.yaml +64 -0
  31. package/index.js +6 -5
  32. package/installer/build.py +124 -0
  33. package/openclaw-plugin/package.json +13 -0
  34. package/openclaw-plugin/src/index.ts +351 -0
  35. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  36. package/package.json +1 -1
  37. package/pyproject.toml +38 -2
  38. package/scripts/bump_version.py +141 -0
  39. package/scripts/check-updates.py +230 -0
  40. package/scripts/convert_blueprints_to_yaml.py +157 -0
  41. package/scripts/dev-install.sh +14 -0
  42. package/scripts/e2e-test.sh +193 -0
  43. package/scripts/install-bundle.sh +171 -0
  44. package/scripts/install.bat +2 -0
  45. package/scripts/install.ps1 +253 -0
  46. package/scripts/install.sh +185 -0
  47. package/scripts/mcp-serve.sh +69 -0
  48. package/scripts/mcp-server.bat +113 -0
  49. package/scripts/mcp-server.ps1 +116 -0
  50. package/scripts/mcp-server.sh +99 -0
  51. package/scripts/pull-models.sh +10 -0
  52. package/scripts/skcapstone +48 -0
  53. package/scripts/verify_install.sh +180 -0
  54. package/scripts/windows/install-tasks.ps1 +406 -0
  55. package/scripts/windows/skcapstone-task.xml +113 -0
  56. package/scripts/windows/uninstall-tasks.ps1 +117 -0
  57. package/skill.yaml +34 -0
  58. package/src/skcapstone/__init__.py +67 -2
  59. package/src/skcapstone/_cli_monolith.py +5916 -0
  60. package/src/skcapstone/_trustee_helpers.py +165 -0
  61. package/src/skcapstone/activity.py +105 -0
  62. package/src/skcapstone/agent_card.py +324 -0
  63. package/src/skcapstone/api.py +1935 -0
  64. package/src/skcapstone/archiver.py +340 -0
  65. package/src/skcapstone/auction.py +485 -0
  66. package/src/skcapstone/baby_agents.py +179 -0
  67. package/src/skcapstone/backup.py +345 -0
  68. package/src/skcapstone/blueprint_registry.py +357 -0
  69. package/src/skcapstone/blueprints/__init__.py +17 -0
  70. package/src/skcapstone/blueprints/builtins/content-studio.yaml +81 -0
  71. package/src/skcapstone/blueprints/builtins/defi-trading.yaml +81 -0
  72. package/src/skcapstone/blueprints/builtins/dev-squadron.yaml +95 -0
  73. package/src/skcapstone/blueprints/builtins/infrastructure-guardian.yaml +107 -0
  74. package/src/skcapstone/blueprints/builtins/legal-council.yaml +54 -0
  75. package/src/skcapstone/blueprints/builtins/ops-monitoring.yaml +67 -0
  76. package/src/skcapstone/blueprints/builtins/research-pod.yaml +69 -0
  77. package/src/skcapstone/blueprints/builtins/sovereign-launch.yaml +90 -0
  78. package/src/skcapstone/blueprints/registry.py +164 -0
  79. package/src/skcapstone/blueprints/schema.py +229 -0
  80. package/src/skcapstone/changelog.py +180 -0
  81. package/src/skcapstone/chat.py +769 -0
  82. package/src/skcapstone/claude_md.py +82 -0
  83. package/src/skcapstone/cli/__init__.py +144 -0
  84. package/src/skcapstone/cli/_common.py +88 -0
  85. package/src/skcapstone/cli/_validators.py +76 -0
  86. package/src/skcapstone/cli/agents.py +425 -0
  87. package/src/skcapstone/cli/agents_spawner.py +322 -0
  88. package/src/skcapstone/cli/agents_trustee.py +593 -0
  89. package/src/skcapstone/cli/alerts.py +248 -0
  90. package/src/skcapstone/cli/anchor.py +132 -0
  91. package/src/skcapstone/cli/archive_cmd.py +208 -0
  92. package/src/skcapstone/cli/backup.py +144 -0
  93. package/src/skcapstone/cli/bench.py +377 -0
  94. package/src/skcapstone/cli/benchmark.py +360 -0
  95. package/src/skcapstone/cli/capabilities_cmd.py +171 -0
  96. package/src/skcapstone/cli/card.py +151 -0
  97. package/src/skcapstone/cli/chat.py +584 -0
  98. package/src/skcapstone/cli/completions.py +64 -0
  99. package/src/skcapstone/cli/config_cmd.py +156 -0
  100. package/src/skcapstone/cli/consciousness.py +421 -0
  101. package/src/skcapstone/cli/context_cmd.py +142 -0
  102. package/src/skcapstone/cli/coord.py +194 -0
  103. package/src/skcapstone/cli/crush_cmd.py +170 -0
  104. package/src/skcapstone/cli/daemon.py +436 -0
  105. package/src/skcapstone/cli/errors_cmd.py +285 -0
  106. package/src/skcapstone/cli/export_cmd.py +156 -0
  107. package/src/skcapstone/cli/gtd.py +529 -0
  108. package/src/skcapstone/cli/housekeeping.py +81 -0
  109. package/src/skcapstone/cli/joule_cmd.py +627 -0
  110. package/src/skcapstone/cli/logs_cmd.py +194 -0
  111. package/src/skcapstone/cli/mcp_cmd.py +32 -0
  112. package/src/skcapstone/cli/memory.py +418 -0
  113. package/src/skcapstone/cli/metrics_cmd.py +136 -0
  114. package/src/skcapstone/cli/migrate.py +62 -0
  115. package/src/skcapstone/cli/mood_cmd.py +144 -0
  116. package/src/skcapstone/cli/mount.py +193 -0
  117. package/src/skcapstone/cli/notify.py +112 -0
  118. package/src/skcapstone/cli/peer.py +154 -0
  119. package/src/skcapstone/cli/peers_dir.py +122 -0
  120. package/src/skcapstone/cli/preflight_cmd.py +83 -0
  121. package/src/skcapstone/cli/profile_cmd.py +310 -0
  122. package/src/skcapstone/cli/record_cmd.py +238 -0
  123. package/src/skcapstone/cli/register_cmd.py +159 -0
  124. package/src/skcapstone/cli/search_cmd.py +156 -0
  125. package/src/skcapstone/cli/service_cmd.py +91 -0
  126. package/src/skcapstone/cli/session.py +127 -0
  127. package/src/skcapstone/cli/setup.py +240 -0
  128. package/src/skcapstone/cli/shell_cmd.py +43 -0
  129. package/src/skcapstone/cli/skills_cmd.py +168 -0
  130. package/src/skcapstone/cli/skseed.py +621 -0
  131. package/src/skcapstone/cli/soul.py +699 -0
  132. package/src/skcapstone/cli/status.py +935 -0
  133. package/src/skcapstone/cli/sync_cmd.py +301 -0
  134. package/src/skcapstone/cli/telegram.py +265 -0
  135. package/src/skcapstone/cli/test_cmd.py +234 -0
  136. package/src/skcapstone/cli/test_connection.py +253 -0
  137. package/src/skcapstone/cli/token.py +207 -0
  138. package/src/skcapstone/cli/trust.py +179 -0
  139. package/src/skcapstone/cli/upgrade_cmd.py +552 -0
  140. package/src/skcapstone/cli/usage_cmd.py +199 -0
  141. package/src/skcapstone/cli/version_cmd.py +162 -0
  142. package/src/skcapstone/cli/watch_cmd.py +342 -0
  143. package/src/skcapstone/client.py +428 -0
  144. package/src/skcapstone/cloud9_bridge.py +522 -0
  145. package/src/skcapstone/completions.py +163 -0
  146. package/src/skcapstone/config_validator.py +674 -0
  147. package/src/skcapstone/connectors/__init__.py +28 -0
  148. package/src/skcapstone/connectors/base.py +446 -0
  149. package/src/skcapstone/connectors/cursor.py +54 -0
  150. package/src/skcapstone/connectors/registry.py +254 -0
  151. package/src/skcapstone/connectors/terminal.py +152 -0
  152. package/src/skcapstone/connectors/vscode.py +60 -0
  153. package/src/skcapstone/consciousness_config.py +119 -0
  154. package/src/skcapstone/consciousness_loop.py +2051 -0
  155. package/src/skcapstone/context_loader.py +516 -0
  156. package/src/skcapstone/context_window.py +314 -0
  157. package/src/skcapstone/conversation_manager.py +238 -0
  158. package/src/skcapstone/conversation_store.py +230 -0
  159. package/src/skcapstone/conversation_summarizer.py +252 -0
  160. package/src/skcapstone/coord_federation.py +296 -0
  161. package/src/skcapstone/coordination.py +101 -7
  162. package/src/skcapstone/crush_integration.py +345 -0
  163. package/src/skcapstone/crush_shim.py +454 -0
  164. package/src/skcapstone/daemon.py +2494 -0
  165. package/src/skcapstone/dashboard.html +396 -0
  166. package/src/skcapstone/dashboard.py +481 -0
  167. package/src/skcapstone/data/model_profiles.yaml +88 -0
  168. package/src/skcapstone/defaults/__init__.py +55 -0
  169. package/src/skcapstone/defaults/lumina/config/skmemory.yaml +13 -0
  170. package/src/skcapstone/defaults/lumina/identity/identity.json +9 -0
  171. package/src/skcapstone/defaults/lumina/memory/long-term/07a8b9c0d1e2-memory-system.json +23 -0
  172. package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +23 -0
  173. package/src/skcapstone/defaults/lumina/memory/long-term/29c0d1e2f3a4-multi-agent-coordination.json +23 -0
  174. package/src/skcapstone/defaults/lumina/memory/long-term/3ad1e2f3a4b5-community-support.json +23 -0
  175. package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +23 -0
  176. package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +23 -0
  177. package/src/skcapstone/defaults/lumina/memory/long-term/c3d4e5f6a7b8-getting-started.json +23 -0
  178. package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +23 -0
  179. package/src/skcapstone/defaults/lumina/memory/long-term/e5f6a7b8c9d0-how-to-contribute.json +23 -0
  180. package/src/skcapstone/defaults/lumina/memory/long-term/f6a7b8c9d0e1-sovereignty-explained.json +23 -0
  181. package/src/skcapstone/defaults/lumina/seeds/curiosity.seed.json +24 -0
  182. package/src/skcapstone/defaults/lumina/seeds/joy.seed.json +24 -0
  183. package/src/skcapstone/defaults/lumina/seeds/love.seed.json +24 -0
  184. package/src/skcapstone/defaults/lumina/seeds/sovereign-awakening.seed.json +43 -0
  185. package/src/skcapstone/defaults/lumina/soul/active.json +6 -0
  186. package/src/skcapstone/defaults/lumina/soul/base.json +22 -0
  187. package/src/skcapstone/defaults/lumina/trust/febs/welcome.feb +79 -0
  188. package/src/skcapstone/defaults/lumina/trust/trust.json +8 -0
  189. package/src/skcapstone/discovery.py +210 -19
  190. package/src/skcapstone/doctor.py +642 -0
  191. package/src/skcapstone/emotion_tracker.py +467 -0
  192. package/src/skcapstone/error_queue.py +405 -0
  193. package/src/skcapstone/export.py +447 -0
  194. package/src/skcapstone/fallback_tracker.py +186 -0
  195. package/src/skcapstone/file_transfer.py +512 -0
  196. package/src/skcapstone/fuse_mount.py +1156 -0
  197. package/src/skcapstone/gui_installer.py +591 -0
  198. package/src/skcapstone/heartbeat.py +611 -0
  199. package/src/skcapstone/housekeeping.py +298 -0
  200. package/src/skcapstone/install_wizard.py +941 -0
  201. package/src/skcapstone/kms.py +942 -0
  202. package/src/skcapstone/kms_scheduler.py +143 -0
  203. package/src/skcapstone/log_config.py +135 -0
  204. package/src/skcapstone/mcp_launcher.py +239 -0
  205. package/src/skcapstone/mcp_server.py +4700 -0
  206. package/src/skcapstone/mcp_tools/__init__.py +94 -0
  207. package/src/skcapstone/mcp_tools/_helpers.py +51 -0
  208. package/src/skcapstone/mcp_tools/agent_tools.py +243 -0
  209. package/src/skcapstone/mcp_tools/ansible_tools.py +232 -0
  210. package/src/skcapstone/mcp_tools/capauth_tools.py +186 -0
  211. package/src/skcapstone/mcp_tools/chat_tools.py +325 -0
  212. package/src/skcapstone/mcp_tools/cloud9_tools.py +115 -0
  213. package/src/skcapstone/mcp_tools/comm_tools.py +104 -0
  214. package/src/skcapstone/mcp_tools/consciousness_tools.py +114 -0
  215. package/src/skcapstone/mcp_tools/coord_tools.py +219 -0
  216. package/src/skcapstone/mcp_tools/deploy_tools.py +202 -0
  217. package/src/skcapstone/mcp_tools/did_tools.py +448 -0
  218. package/src/skcapstone/mcp_tools/emotion_tools.py +62 -0
  219. package/src/skcapstone/mcp_tools/file_tools.py +169 -0
  220. package/src/skcapstone/mcp_tools/fortress_tools.py +120 -0
  221. package/src/skcapstone/mcp_tools/gtd_tools.py +821 -0
  222. package/src/skcapstone/mcp_tools/health_tools.py +44 -0
  223. package/src/skcapstone/mcp_tools/heartbeat_tools.py +195 -0
  224. package/src/skcapstone/mcp_tools/kms_tools.py +123 -0
  225. package/src/skcapstone/mcp_tools/memory_tools.py +222 -0
  226. package/src/skcapstone/mcp_tools/model_tools.py +75 -0
  227. package/src/skcapstone/mcp_tools/notification_tools.py +92 -0
  228. package/src/skcapstone/mcp_tools/promoter_tools.py +101 -0
  229. package/src/skcapstone/mcp_tools/pubsub_tools.py +183 -0
  230. package/src/skcapstone/mcp_tools/security_tools.py +110 -0
  231. package/src/skcapstone/mcp_tools/skchat_tools.py +175 -0
  232. package/src/skcapstone/mcp_tools/skcomm_tools.py +122 -0
  233. package/src/skcapstone/mcp_tools/skills_tools.py +127 -0
  234. package/src/skcapstone/mcp_tools/skseed_tools.py +255 -0
  235. package/src/skcapstone/mcp_tools/skstacks_tools.py +288 -0
  236. package/src/skcapstone/mcp_tools/soul_tools.py +476 -0
  237. package/src/skcapstone/mcp_tools/sync_tools.py +92 -0
  238. package/src/skcapstone/mcp_tools/telegram_tools.py +477 -0
  239. package/src/skcapstone/mcp_tools/trust_tools.py +118 -0
  240. package/src/skcapstone/mcp_tools/trustee_tools.py +345 -0
  241. package/src/skcapstone/mdns_discovery.py +313 -0
  242. package/src/skcapstone/memory_adapter.py +333 -0
  243. package/src/skcapstone/memory_compressor.py +379 -0
  244. package/src/skcapstone/memory_curator.py +256 -0
  245. package/src/skcapstone/memory_engine.py +132 -13
  246. package/src/skcapstone/memory_fortress.py +529 -0
  247. package/src/skcapstone/memory_promoter.py +722 -0
  248. package/src/skcapstone/memory_verifier.py +260 -0
  249. package/src/skcapstone/message_crypto.py +215 -0
  250. package/src/skcapstone/metrics.py +832 -0
  251. package/src/skcapstone/migrate_memories.py +181 -0
  252. package/src/skcapstone/migrate_multi_agent.py +248 -0
  253. package/src/skcapstone/model_router.py +319 -0
  254. package/src/skcapstone/models.py +35 -4
  255. package/src/skcapstone/mood.py +344 -0
  256. package/src/skcapstone/notifications.py +380 -0
  257. package/src/skcapstone/onboard.py +901 -0
  258. package/src/skcapstone/peer_directory.py +324 -0
  259. package/src/skcapstone/peers.py +329 -0
  260. package/src/skcapstone/pillars/identity.py +84 -14
  261. package/src/skcapstone/pillars/memory.py +3 -1
  262. package/src/skcapstone/pillars/security.py +108 -15
  263. package/src/skcapstone/pillars/sync.py +78 -26
  264. package/src/skcapstone/pillars/trust.py +95 -33
  265. package/src/skcapstone/plugins.py +244 -0
  266. package/src/skcapstone/preflight.py +670 -0
  267. package/src/skcapstone/prompt_adapter.py +564 -0
  268. package/src/skcapstone/providers/__init__.py +13 -0
  269. package/src/skcapstone/providers/cloud.py +1061 -0
  270. package/src/skcapstone/providers/docker.py +759 -0
  271. package/src/skcapstone/providers/local.py +1193 -0
  272. package/src/skcapstone/providers/proxmox.py +447 -0
  273. package/src/skcapstone/pubsub.py +516 -0
  274. package/src/skcapstone/rate_limiter.py +119 -0
  275. package/src/skcapstone/register.py +241 -0
  276. package/src/skcapstone/registry_client.py +151 -0
  277. package/src/skcapstone/response_cache.py +194 -0
  278. package/src/skcapstone/response_scorer.py +225 -0
  279. package/src/skcapstone/runtime.py +89 -33
  280. package/src/skcapstone/scheduled_tasks.py +439 -0
  281. package/src/skcapstone/self_healing.py +341 -0
  282. package/src/skcapstone/service_health.py +228 -0
  283. package/src/skcapstone/session_capture.py +268 -0
  284. package/src/skcapstone/session_recorder.py +210 -0
  285. package/src/skcapstone/session_replayer.py +189 -0
  286. package/src/skcapstone/session_skills.py +263 -0
  287. package/src/skcapstone/shell.py +779 -0
  288. package/src/skcapstone/skills/__init__.py +1 -1
  289. package/src/skcapstone/skills/syncthing_setup.py +143 -41
  290. package/src/skcapstone/skjoule.py +861 -0
  291. package/src/skcapstone/snapshots.py +489 -0
  292. package/src/skcapstone/soul.py +1060 -0
  293. package/src/skcapstone/soul_switch.py +255 -0
  294. package/src/skcapstone/spawner.py +544 -0
  295. package/src/skcapstone/state_diff.py +401 -0
  296. package/src/skcapstone/summary.py +270 -0
  297. package/src/skcapstone/sync/backends.py +196 -2
  298. package/src/skcapstone/sync/engine.py +7 -5
  299. package/src/skcapstone/sync/models.py +4 -1
  300. package/src/skcapstone/sync/vault.py +356 -18
  301. package/src/skcapstone/sync_engine.py +363 -0
  302. package/src/skcapstone/sync_watcher.py +745 -0
  303. package/src/skcapstone/systemd.py +331 -0
  304. package/src/skcapstone/team_comms.py +476 -0
  305. package/src/skcapstone/team_engine.py +522 -0
  306. package/src/skcapstone/testrunner.py +300 -0
  307. package/src/skcapstone/tls.py +150 -0
  308. package/src/skcapstone/tokens.py +5 -5
  309. package/src/skcapstone/trust_calibration.py +202 -0
  310. package/src/skcapstone/trust_graph.py +449 -0
  311. package/src/skcapstone/trustee_monitor.py +385 -0
  312. package/src/skcapstone/trustee_ops.py +425 -0
  313. package/src/skcapstone/unified_search.py +421 -0
  314. package/src/skcapstone/uninstall_wizard.py +694 -0
  315. package/src/skcapstone/usage.py +331 -0
  316. package/src/skcapstone/version_check.py +148 -0
  317. package/src/skcapstone/warmth_anchor.py +333 -0
  318. package/src/skcapstone/whoami.py +294 -0
  319. package/systemd/skcapstone-api.socket +9 -0
  320. package/systemd/skcapstone-memory-compress.service +18 -0
  321. package/systemd/skcapstone-memory-compress.timer +11 -0
  322. package/systemd/skcapstone.service +36 -0
  323. package/systemd/skcapstone@.service +50 -0
  324. package/systemd/skcomm-heartbeat.service +18 -0
  325. package/systemd/skcomm-heartbeat.timer +12 -0
  326. package/systemd/skcomm-queue-drain.service +17 -0
  327. package/systemd/skcomm-queue-drain.timer +12 -0
  328. package/tests/conftest.py +13 -1
  329. package/tests/integration/__init__.py +1 -0
  330. package/tests/integration/test_consciousness_e2e.py +877 -0
  331. package/tests/integration/test_skills_registry.py +744 -0
  332. package/tests/test_agent_card.py +190 -0
  333. package/tests/test_agent_runtime.py +1283 -0
  334. package/tests/test_alerts_cmd.py +291 -0
  335. package/tests/test_archiver.py +498 -0
  336. package/tests/test_backup.py +254 -0
  337. package/tests/test_benchmark.py +366 -0
  338. package/tests/test_blueprints.py +457 -0
  339. package/tests/test_capabilities.py +257 -0
  340. package/tests/test_changelog.py +254 -0
  341. package/tests/test_chat.py +385 -0
  342. package/tests/test_claude_md.py +271 -0
  343. package/tests/test_cli_chat_llm.py +336 -0
  344. package/tests/test_cli_completions.py +390 -0
  345. package/tests/test_cli_init_reset.py +164 -0
  346. package/tests/test_cli_memory.py +208 -0
  347. package/tests/test_cli_profile.py +294 -0
  348. package/tests/test_cli_skills.py +223 -0
  349. package/tests/test_cli_status.py +395 -0
  350. package/tests/test_cli_test_cmd.py +206 -0
  351. package/tests/test_cli_test_connection.py +364 -0
  352. package/tests/test_cloud9_bridge.py +260 -0
  353. package/tests/test_cloud_provider.py +449 -0
  354. package/tests/test_cloud_providers.py +522 -0
  355. package/tests/test_completions.py +158 -0
  356. package/tests/test_component_manager.py +398 -0
  357. package/tests/test_config_reload.py +386 -0
  358. package/tests/test_config_validate.py +529 -0
  359. package/tests/test_consciousness_e2e.py +296 -0
  360. package/tests/test_consciousness_loop.py +1289 -0
  361. package/tests/test_context_loader.py +310 -0
  362. package/tests/test_conversation_api.py +306 -0
  363. package/tests/test_conversation_manager.py +381 -0
  364. package/tests/test_conversation_store.py +391 -0
  365. package/tests/test_conversation_summarizer.py +302 -0
  366. package/tests/test_cross_package.py +791 -0
  367. package/tests/test_crush_shim.py +519 -0
  368. package/tests/test_daemon.py +781 -0
  369. package/tests/test_daemon_shutdown.py +309 -0
  370. package/tests/test_dashboard.py +454 -0
  371. package/tests/test_discovery.py +200 -6
  372. package/tests/test_docker_provider.py +966 -0
  373. package/tests/test_doctor.py +257 -0
  374. package/tests/test_doctor_fix.py +351 -0
  375. package/tests/test_e2e_automated.py +292 -0
  376. package/tests/test_error_queue.py +404 -0
  377. package/tests/test_export.py +441 -0
  378. package/tests/test_fallback_tracker.py +219 -0
  379. package/tests/test_file_transfer.py +397 -0
  380. package/tests/test_fuse_mount.py +832 -0
  381. package/tests/test_health_loop.py +422 -0
  382. package/tests/test_heartbeat.py +354 -0
  383. package/tests/test_housekeeping.py +195 -0
  384. package/tests/test_identity_capauth.py +307 -0
  385. package/tests/test_identity_pillar.py +117 -0
  386. package/tests/test_install_wizard.py +68 -0
  387. package/tests/test_integration.py +325 -0
  388. package/tests/test_kms.py +495 -0
  389. package/tests/test_llm_providers.py +265 -0
  390. package/tests/test_local_provider.py +591 -0
  391. package/tests/test_log_config.py +199 -0
  392. package/tests/test_logs_cmd.py +287 -0
  393. package/tests/test_mcp_server.py +1909 -0
  394. package/tests/test_memory_adapter.py +339 -0
  395. package/tests/test_memory_curator.py +218 -0
  396. package/tests/test_memory_engine.py +6 -0
  397. package/tests/test_memory_fortress.py +571 -0
  398. package/tests/test_memory_pillar.py +119 -0
  399. package/tests/test_memory_promoter.py +445 -0
  400. package/tests/test_memory_verifier.py +420 -0
  401. package/tests/test_message_crypto.py +187 -0
  402. package/tests/test_metrics.py +632 -0
  403. package/tests/test_migrate_memories.py +464 -0
  404. package/tests/test_model_router.py +546 -0
  405. package/tests/test_mood.py +394 -0
  406. package/tests/test_multi_agent.py +269 -0
  407. package/tests/test_notifications.py +270 -0
  408. package/tests/test_onboard.py +500 -0
  409. package/tests/test_peer_directory.py +395 -0
  410. package/tests/test_peers.py +248 -0
  411. package/tests/test_pillars.py +87 -9
  412. package/tests/test_preflight.py +484 -0
  413. package/tests/test_prompt_adapter.py +331 -0
  414. package/tests/test_proxmox_provider.py +571 -0
  415. package/tests/test_pubsub.py +377 -0
  416. package/tests/test_rate_limiter.py +121 -0
  417. package/tests/test_registry_client.py +129 -0
  418. package/tests/test_response_cache.py +312 -0
  419. package/tests/test_response_scorer.py +294 -0
  420. package/tests/test_runtime.py +59 -0
  421. package/tests/test_scheduled_tasks.py +451 -0
  422. package/tests/test_security.py +250 -0
  423. package/tests/test_security_pillar.py +213 -0
  424. package/tests/test_self_healing.py +171 -0
  425. package/tests/test_session_capture.py +200 -0
  426. package/tests/test_session_recorder.py +360 -0
  427. package/tests/test_session_skills.py +235 -0
  428. package/tests/test_shell.py +210 -0
  429. package/tests/test_snapshots.py +549 -0
  430. package/tests/test_soul.py +984 -0
  431. package/tests/test_soul_swap.py +406 -0
  432. package/tests/test_spawner.py +211 -0
  433. package/tests/test_state_diff.py +173 -0
  434. package/tests/test_summary.py +135 -0
  435. package/tests/test_sync.py +315 -5
  436. package/tests/test_sync_backends.py +560 -0
  437. package/tests/test_sync_engine.py +482 -0
  438. package/tests/test_sync_pillar.py +344 -0
  439. package/tests/test_sync_pipeline.py +364 -0
  440. package/tests/test_sync_vault.py +581 -0
  441. package/tests/test_syncthing_setup.py +168 -22
  442. package/tests/test_systemd.py +323 -0
  443. package/tests/test_team_comms.py +408 -0
  444. package/tests/test_team_engine.py +397 -0
  445. package/tests/test_testrunner.py +238 -0
  446. package/tests/test_trust_calibration.py +204 -0
  447. package/tests/test_trust_graph.py +207 -0
  448. package/tests/test_trust_pillar.py +291 -0
  449. package/tests/test_trustee_cli.py +427 -0
  450. package/tests/test_trustee_cli_integration.py +325 -0
  451. package/tests/test_trustee_monitor.py +394 -0
  452. package/tests/test_trustee_ops.py +355 -0
  453. package/tests/test_unified_search.py +363 -0
  454. package/tests/test_uninstall_wizard.py +193 -0
  455. package/tests/test_usage.py +333 -0
  456. package/tests/test_version_cmd.py +355 -0
  457. package/tests/test_warmth_anchor.py +162 -0
  458. package/tests/test_whoami.py +245 -0
  459. package/tests/test_ws.py +311 -0
  460. package/.cursorrules +0 -33
  461. package/src/skcapstone/cli.py +0 -1441
@@ -0,0 +1,516 @@
1
+ """
2
+ Sovereign pub/sub — lightweight real-time messaging for agent meshes.
3
+
4
+ Topic-based publish/subscribe built on the file transport layer.
5
+ Designed for 100+ node scale without requiring a central broker.
6
+ Each agent manages its own subscriptions and topic inboxes.
7
+
8
+ Architecture:
9
+ Publishers write topic messages to a shared topic directory.
10
+ Subscribers poll their subscribed topics or register callbacks.
11
+ Syncthing distributes topic directories across the mesh.
12
+
13
+ Storage layout:
14
+ ~/.skcapstone/pubsub/
15
+ ├── subscriptions.json # Agent's active subscriptions
16
+ ├── topics/ # Topic message directories
17
+ │ ├── system.health/ # Topic: system.health
18
+ │ │ ├── msg-<uuid>.json
19
+ │ │ └── ...
20
+ │ └── team.dev/ # Topic: team.dev
21
+ │ └── ...
22
+ └── dead-letter/ # Undeliverable messages
23
+
24
+ Usage:
25
+ bus = PubSub(home, agent_name="opus")
26
+ bus.subscribe("system.health")
27
+ bus.subscribe("team.*") # wildcard
28
+ bus.publish("system.health", {"status": "alive", "load": 0.4})
29
+ messages = bus.poll("system.health", since=last_check)
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import fnmatch
36
+ import json
37
+ import logging
38
+ import os
39
+ import uuid
40
+ from datetime import datetime, timedelta, timezone
41
+ from pathlib import Path
42
+ from typing import Any, Callable, Optional
43
+
44
+ from pydantic import BaseModel, Field
45
+
46
+ logger = logging.getLogger("skcapstone.pubsub")
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Models
51
+ # ---------------------------------------------------------------------------
52
+
53
+ class TopicMessage(BaseModel):
54
+ """A single published message on a topic."""
55
+
56
+ message_id: str = Field(default_factory=lambda: str(uuid.uuid4())[:12])
57
+ topic: str
58
+ sender: str
59
+ payload: dict[str, Any] = Field(default_factory=dict)
60
+ published_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
61
+ ttl_seconds: int = Field(default=86400, description="Message expiry (default 24h)")
62
+ tags: list[str] = Field(default_factory=list)
63
+
64
+ @property
65
+ def is_expired(self) -> bool:
66
+ """Check if this message has expired."""
67
+ expires = self.published_at + timedelta(seconds=self.ttl_seconds)
68
+ return datetime.now(timezone.utc) > expires
69
+
70
+
71
+ class Subscription(BaseModel):
72
+ """An agent's subscription to a topic pattern."""
73
+
74
+ pattern: str = Field(description="Topic name or glob pattern (e.g., 'team.*')")
75
+ subscribed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
76
+ last_read: Optional[datetime] = None
77
+ message_count: int = 0
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # PubSub
82
+ # ---------------------------------------------------------------------------
83
+
84
+ class PubSub:
85
+ """Sovereign publish/subscribe message bus.
86
+
87
+ File-based, mesh-friendly, zero-broker architecture.
88
+ Each agent runs its own PubSub instance that reads from
89
+ shared topic directories (distributed via Syncthing).
90
+
91
+ Args:
92
+ home: Agent home directory (~/.skcapstone).
93
+ agent_name: Name of the local agent.
94
+ max_topic_messages: Maximum messages per topic before pruning.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ home: Path,
100
+ agent_name: str = "anonymous",
101
+ max_topic_messages: int = 1000,
102
+ ) -> None:
103
+ self._home = home
104
+ self._agent = agent_name
105
+ self._max_messages = max_topic_messages
106
+ self._pubsub_dir = home / "pubsub"
107
+ self._topics_dir = self._pubsub_dir / "topics"
108
+ self._dead_letter_dir = self._pubsub_dir / "dead-letter"
109
+ self._subs_file = self._pubsub_dir / "subscriptions.json"
110
+ self._callbacks: dict[str, list[Callable]] = {}
111
+
112
+ def initialize(self) -> None:
113
+ """Create the pub/sub directory structure."""
114
+ self._pubsub_dir.mkdir(parents=True, exist_ok=True)
115
+ self._topics_dir.mkdir(exist_ok=True)
116
+ self._dead_letter_dir.mkdir(exist_ok=True)
117
+
118
+ def publish(
119
+ self,
120
+ topic: str,
121
+ payload: dict[str, Any],
122
+ ttl_seconds: int = 86400,
123
+ tags: Optional[list[str]] = None,
124
+ ) -> TopicMessage:
125
+ """Publish a message to a topic.
126
+
127
+ Creates the topic directory if it doesn't exist and writes
128
+ the message as a JSON file. Prunes old messages if the topic
129
+ exceeds max_topic_messages.
130
+
131
+ Args:
132
+ topic: Topic name (e.g., 'system.health', 'team.dev').
133
+ payload: Message payload dict.
134
+ ttl_seconds: Message time-to-live in seconds.
135
+ tags: Optional tags for filtering.
136
+
137
+ Returns:
138
+ The published TopicMessage.
139
+ """
140
+ self.initialize()
141
+
142
+ msg = TopicMessage(
143
+ topic=topic,
144
+ sender=self._agent,
145
+ payload=payload,
146
+ ttl_seconds=ttl_seconds,
147
+ tags=tags or [],
148
+ )
149
+
150
+ topic_dir = self._topics_dir / _sanitize_topic(topic)
151
+ topic_dir.mkdir(parents=True, exist_ok=True)
152
+
153
+ filename = f"msg-{msg.message_id}.json"
154
+ tmp_path = topic_dir / f".{filename}.tmp"
155
+ final_path = topic_dir / filename
156
+
157
+ tmp_path.write_text(
158
+ msg.model_dump_json(indent=2),
159
+ encoding="utf-8",
160
+ )
161
+ tmp_path.rename(final_path)
162
+
163
+ self._prune_topic(topic_dir)
164
+
165
+ logger.debug("Published to '%s': %s", topic, msg.message_id)
166
+ return msg
167
+
168
+ def subscribe(self, pattern: str) -> Subscription:
169
+ """Subscribe to a topic or topic pattern.
170
+
171
+ Supports glob patterns (e.g., 'team.*', 'system.health',
172
+ '*.critical'). The subscription is persisted to disk.
173
+
174
+ Args:
175
+ pattern: Topic name or glob pattern.
176
+
177
+ Returns:
178
+ The new or existing Subscription.
179
+ """
180
+ self.initialize()
181
+ subs = self._load_subscriptions()
182
+
183
+ existing = subs.get(pattern)
184
+ if existing:
185
+ return existing
186
+
187
+ sub = Subscription(pattern=pattern)
188
+ subs[pattern] = sub
189
+ self._save_subscriptions(subs)
190
+
191
+ logger.info("Agent '%s' subscribed to '%s'", self._agent, pattern)
192
+ return sub
193
+
194
+ def unsubscribe(self, pattern: str) -> bool:
195
+ """Remove a subscription.
196
+
197
+ Args:
198
+ pattern: The pattern to unsubscribe from.
199
+
200
+ Returns:
201
+ True if the subscription existed and was removed.
202
+ """
203
+ subs = self._load_subscriptions()
204
+ if pattern not in subs:
205
+ return False
206
+
207
+ del subs[pattern]
208
+ self._save_subscriptions(subs)
209
+ self._callbacks.pop(pattern, None)
210
+ logger.info("Agent '%s' unsubscribed from '%s'", self._agent, pattern)
211
+ return True
212
+
213
+ def poll(
214
+ self,
215
+ topic: Optional[str] = None,
216
+ since: Optional[datetime] = None,
217
+ limit: int = 100,
218
+ ) -> list[TopicMessage]:
219
+ """Poll for new messages on subscribed topics.
220
+
221
+ Args:
222
+ topic: Specific topic to poll (None = all subscribed).
223
+ since: Only return messages after this timestamp.
224
+ limit: Maximum messages to return.
225
+
226
+ Returns:
227
+ List of TopicMessage objects, newest first.
228
+ """
229
+ self.initialize()
230
+
231
+ if topic:
232
+ topics = [topic]
233
+ else:
234
+ subs = self._load_subscriptions()
235
+ topics = self._resolve_subscribed_topics(subs)
236
+
237
+ messages: list[TopicMessage] = []
238
+ for t in topics:
239
+ topic_dir = self._topics_dir / _sanitize_topic(t)
240
+ if not topic_dir.is_dir():
241
+ continue
242
+ for msg_file in sorted(topic_dir.glob("msg-*.json")):
243
+ try:
244
+ data = json.loads(msg_file.read_text(encoding="utf-8"))
245
+ msg = TopicMessage.model_validate(data)
246
+ if msg.is_expired:
247
+ continue
248
+ if since and msg.published_at <= since:
249
+ continue
250
+ messages.append(msg)
251
+ except (json.JSONDecodeError, Exception) as exc:
252
+ logger.warning("Skipping invalid message %s: %s", msg_file.name, exc)
253
+
254
+ messages.sort(key=lambda m: m.published_at, reverse=True)
255
+
256
+ if topic:
257
+ subs = self._load_subscriptions()
258
+ for pattern, sub in subs.items():
259
+ if fnmatch.fnmatch(topic, pattern):
260
+ sub.last_read = datetime.now(timezone.utc)
261
+ sub.message_count += len(messages[:limit])
262
+ self._save_subscriptions(subs)
263
+
264
+ return messages[:limit]
265
+
266
+ def on_message(self, pattern: str, callback: Callable[[TopicMessage], None]) -> None:
267
+ """Register a callback for messages matching a pattern.
268
+
269
+ Callbacks are triggered during poll_and_dispatch().
270
+
271
+ Args:
272
+ pattern: Topic pattern to match.
273
+ callback: Function called with each matching TopicMessage.
274
+ """
275
+ if pattern not in self._callbacks:
276
+ self._callbacks[pattern] = []
277
+ self._callbacks[pattern].append(callback)
278
+ self.subscribe(pattern)
279
+
280
+ def poll_and_dispatch(self, since: Optional[datetime] = None) -> int:
281
+ """Poll all subscriptions and dispatch to registered callbacks.
282
+
283
+ Args:
284
+ since: Only process messages after this timestamp.
285
+
286
+ Returns:
287
+ Number of messages dispatched.
288
+ """
289
+ dispatched = 0
290
+ messages = self.poll(since=since)
291
+
292
+ for msg in messages:
293
+ for pattern, callbacks in self._callbacks.items():
294
+ if fnmatch.fnmatch(msg.topic, pattern):
295
+ for cb in callbacks:
296
+ try:
297
+ cb(msg)
298
+ dispatched += 1
299
+ except Exception as exc:
300
+ logger.error(
301
+ "Callback error for '%s' on '%s': %s",
302
+ pattern, msg.topic, exc,
303
+ )
304
+
305
+ return dispatched
306
+
307
+ def list_topics(self) -> list[dict[str, Any]]:
308
+ """List all known topics with message counts.
309
+
310
+ Returns:
311
+ List of dicts with topic name, message count, and latest timestamp.
312
+ """
313
+ self.initialize()
314
+ topics: list[dict[str, Any]] = []
315
+
316
+ if not self._topics_dir.is_dir():
317
+ return topics
318
+
319
+ for topic_dir in sorted(self._topics_dir.iterdir()):
320
+ if not topic_dir.is_dir():
321
+ continue
322
+ msg_files = list(topic_dir.glob("msg-*.json"))
323
+ latest = None
324
+ if msg_files:
325
+ try:
326
+ newest = max(msg_files, key=lambda f: f.stat().st_mtime)
327
+ data = json.loads(newest.read_text(encoding="utf-8"))
328
+ latest = data.get("published_at")
329
+ except (json.JSONDecodeError, OSError):
330
+ pass
331
+
332
+ topics.append({
333
+ "topic": _unsanitize_topic(topic_dir.name),
334
+ "messages": len(msg_files),
335
+ "latest": latest,
336
+ })
337
+
338
+ return topics
339
+
340
+ def list_subscriptions(self) -> dict[str, Subscription]:
341
+ """Return the agent's current subscriptions."""
342
+ return self._load_subscriptions()
343
+
344
+ def purge_expired(self) -> int:
345
+ """Remove expired messages from all topics.
346
+
347
+ Returns:
348
+ Number of expired messages removed.
349
+ """
350
+ removed = 0
351
+ if not self._topics_dir.is_dir():
352
+ return removed
353
+
354
+ for topic_dir in self._topics_dir.iterdir():
355
+ if not topic_dir.is_dir():
356
+ continue
357
+ for msg_file in topic_dir.glob("msg-*.json"):
358
+ try:
359
+ data = json.loads(msg_file.read_text(encoding="utf-8"))
360
+ msg = TopicMessage.model_validate(data)
361
+ if msg.is_expired:
362
+ msg_file.unlink()
363
+ removed += 1
364
+ except (json.JSONDecodeError, Exception):
365
+ pass
366
+
367
+ if removed:
368
+ logger.info("Purged %d expired messages", removed)
369
+ return removed
370
+
371
+ def status(self) -> dict[str, Any]:
372
+ """Return pub/sub status summary."""
373
+ subs = self._load_subscriptions()
374
+ topics = self.list_topics()
375
+ total_messages = sum(t["messages"] for t in topics)
376
+
377
+ return {
378
+ "agent": self._agent,
379
+ "subscriptions": len(subs),
380
+ "topics": len(topics),
381
+ "total_messages": total_messages,
382
+ "callbacks_registered": sum(len(cbs) for cbs in self._callbacks.values()),
383
+ "pubsub_dir": str(self._pubsub_dir),
384
+ }
385
+
386
+ def topic_stats(self) -> list[dict[str, Any]]:
387
+ """Return per-topic statistics for live (non-expired) messages.
388
+
389
+ Returns:
390
+ List of dicts with:
391
+ - topic: topic name
392
+ - message_count: number of live (non-expired) messages
393
+ - oldest_message_age_seconds: seconds since oldest live message,
394
+ or None if no live messages exist
395
+ """
396
+ self.initialize()
397
+ stats: list[dict[str, Any]] = []
398
+
399
+ if not self._topics_dir.is_dir():
400
+ return stats
401
+
402
+ now = datetime.now(timezone.utc)
403
+
404
+ for topic_dir in sorted(self._topics_dir.iterdir()):
405
+ if not topic_dir.is_dir():
406
+ continue
407
+
408
+ live_timestamps: list[datetime] = []
409
+ for msg_file in topic_dir.glob("msg-*.json"):
410
+ try:
411
+ data = json.loads(msg_file.read_text(encoding="utf-8"))
412
+ msg = TopicMessage.model_validate(data)
413
+ if not msg.is_expired:
414
+ live_timestamps.append(msg.published_at)
415
+ except (json.JSONDecodeError, Exception):
416
+ pass
417
+
418
+ oldest_age: Optional[float] = None
419
+ if live_timestamps:
420
+ oldest_age = (now - min(live_timestamps)).total_seconds()
421
+
422
+ stats.append({
423
+ "topic": _unsanitize_topic(topic_dir.name),
424
+ "message_count": len(live_timestamps),
425
+ "oldest_message_age_seconds": oldest_age,
426
+ })
427
+
428
+ return stats
429
+
430
+ async def start_expiry_task(self, interval: int = 300) -> None:
431
+ """Periodically purge expired messages from all topics.
432
+
433
+ Designed to be launched as a long-running asyncio background task::
434
+
435
+ asyncio.create_task(bus.start_expiry_task())
436
+
437
+ The first sweep runs after ``interval`` seconds, then repeats.
438
+ File I/O is offloaded to the default executor so the event loop
439
+ is not blocked.
440
+
441
+ Args:
442
+ interval: Seconds between expiry sweeps (default 300).
443
+ """
444
+ logger.info("PubSub expiry task started (interval=%ds)", interval)
445
+ loop = asyncio.get_running_loop()
446
+ while True:
447
+ await asyncio.sleep(interval)
448
+ try:
449
+ removed = await loop.run_in_executor(None, self.purge_expired)
450
+ if removed:
451
+ logger.info("Expiry sweep removed %d messages", removed)
452
+ except Exception as exc:
453
+ logger.error("Expiry sweep failed: %s", exc)
454
+
455
+ # -------------------------------------------------------------------
456
+ # Internal helpers
457
+ # -------------------------------------------------------------------
458
+
459
+ def _load_subscriptions(self) -> dict[str, Subscription]:
460
+ """Load subscriptions from disk."""
461
+ if not self._subs_file.exists():
462
+ return {}
463
+ try:
464
+ data = json.loads(self._subs_file.read_text(encoding="utf-8"))
465
+ return {k: Subscription.model_validate(v) for k, v in data.items()}
466
+ except (json.JSONDecodeError, Exception) as exc:
467
+ logger.warning("Failed to load subscriptions: %s", exc)
468
+ return {}
469
+
470
+ def _save_subscriptions(self, subs: dict[str, Subscription]) -> None:
471
+ """Persist subscriptions to disk."""
472
+ self._pubsub_dir.mkdir(parents=True, exist_ok=True)
473
+ data = {k: v.model_dump(mode="json") for k, v in subs.items()}
474
+ self._subs_file.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
475
+
476
+ def _resolve_subscribed_topics(self, subs: dict[str, Subscription]) -> list[str]:
477
+ """Resolve subscription patterns to actual topic directories."""
478
+ if not self._topics_dir.is_dir():
479
+ return []
480
+
481
+ all_topics = [
482
+ _unsanitize_topic(d.name)
483
+ for d in self._topics_dir.iterdir()
484
+ if d.is_dir()
485
+ ]
486
+
487
+ matched: set[str] = set()
488
+ for pattern in subs:
489
+ for topic in all_topics:
490
+ if fnmatch.fnmatch(topic, pattern):
491
+ matched.add(topic)
492
+
493
+ return sorted(matched)
494
+
495
+ def _prune_topic(self, topic_dir: Path) -> None:
496
+ """Remove oldest messages if topic exceeds max size."""
497
+ msg_files = sorted(topic_dir.glob("msg-*.json"), key=lambda f: f.stat().st_mtime)
498
+ excess = len(msg_files) - self._max_messages
499
+ if excess > 0:
500
+ for f in msg_files[:excess]:
501
+ f.unlink()
502
+ logger.debug("Pruned %d old messages from %s", excess, topic_dir.name)
503
+
504
+
505
+ # ---------------------------------------------------------------------------
506
+ # Helpers
507
+ # ---------------------------------------------------------------------------
508
+
509
+ def _sanitize_topic(topic: str) -> str:
510
+ """Convert topic name to filesystem-safe directory name."""
511
+ return topic.replace("/", "--").replace(" ", "_")
512
+
513
+
514
+ def _unsanitize_topic(dirname: str) -> str:
515
+ """Reverse of _sanitize_topic."""
516
+ return dirname.replace("--", "/").replace("_", " ")
@@ -0,0 +1,119 @@
1
+ """Token-bucket rate limiter for the daemon HTTP API.
2
+
3
+ Each client IP gets an independent token bucket. Buckets refill
4
+ continuously at ``rate`` tokens/second up to ``capacity``.
5
+
6
+ Usage::
7
+
8
+ limiter = RateLimiter(requests_per_minute=100)
9
+ if not limiter.is_allowed("127.0.0.1"):
10
+ # return HTTP 429
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ import time
17
+ from typing import Dict
18
+
19
+
20
+ class TokenBucket:
21
+ """Single-IP token bucket.
22
+
23
+ Args:
24
+ rate: Refill rate in tokens per second.
25
+ capacity: Maximum token capacity (also the initial fill level).
26
+ """
27
+
28
+ def __init__(self, rate: float, capacity: int) -> None:
29
+ if rate <= 0:
30
+ raise ValueError("rate must be positive")
31
+ if capacity <= 0:
32
+ raise ValueError("capacity must be positive")
33
+ self._rate = rate
34
+ self._capacity = capacity
35
+ self._tokens = float(capacity)
36
+ self._last_refill = time.monotonic()
37
+ self._lock = threading.Lock()
38
+
39
+ # ------------------------------------------------------------------
40
+ # Public interface
41
+ # ------------------------------------------------------------------
42
+
43
+ def consume(self, count: int = 1) -> bool:
44
+ """Try to consume ``count`` tokens.
45
+
46
+ Returns:
47
+ True if the tokens were available and consumed, False otherwise.
48
+ """
49
+ with self._lock:
50
+ self._refill()
51
+ if self._tokens >= count:
52
+ self._tokens -= count
53
+ return True
54
+ return False
55
+
56
+ @property
57
+ def tokens(self) -> float:
58
+ """Current token level (approximate — does not acquire the lock)."""
59
+ return self._tokens
60
+
61
+ # ------------------------------------------------------------------
62
+ # Private helpers
63
+ # ------------------------------------------------------------------
64
+
65
+ def _refill(self) -> None:
66
+ now = time.monotonic()
67
+ elapsed = now - self._last_refill
68
+ self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
69
+ self._last_refill = now
70
+
71
+
72
+ class RateLimiter:
73
+ """Per-IP token-bucket rate limiter.
74
+
75
+ Args:
76
+ requests_per_minute: Allowed requests per minute per IP (default 100).
77
+ """
78
+
79
+ def __init__(self, requests_per_minute: int = 100) -> None:
80
+ if requests_per_minute <= 0:
81
+ raise ValueError("requests_per_minute must be positive")
82
+ self._rpm = requests_per_minute
83
+ self._rate = requests_per_minute / 60.0 # tokens per second
84
+ self._capacity = requests_per_minute
85
+ self._buckets: Dict[str, TokenBucket] = {}
86
+ self._lock = threading.Lock()
87
+
88
+ # ------------------------------------------------------------------
89
+ # Public interface
90
+ # ------------------------------------------------------------------
91
+
92
+ def is_allowed(self, ip: str) -> bool:
93
+ """Return True if the request from ``ip`` is within the rate limit."""
94
+ bucket = self._get_or_create(ip)
95
+ return bucket.consume()
96
+
97
+ def reset(self, ip: str) -> None:
98
+ """Remove the bucket for ``ip``, clearing its history."""
99
+ with self._lock:
100
+ self._buckets.pop(ip, None)
101
+
102
+ def clear(self) -> None:
103
+ """Remove all buckets (useful in tests)."""
104
+ with self._lock:
105
+ self._buckets.clear()
106
+
107
+ @property
108
+ def requests_per_minute(self) -> int:
109
+ return self._rpm
110
+
111
+ # ------------------------------------------------------------------
112
+ # Private helpers
113
+ # ------------------------------------------------------------------
114
+
115
+ def _get_or_create(self, ip: str) -> TokenBucket:
116
+ with self._lock:
117
+ if ip not in self._buckets:
118
+ self._buckets[ip] = TokenBucket(self._rate, self._capacity)
119
+ return self._buckets[ip]