@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,447 @@
1
+ """Sovereign agent state export and import.
2
+
3
+ Produces a portable JSON bundle containing the full agent state:
4
+ identity, soul, memories, conversations, and config. Suitable for
5
+ migrating an agent to a new machine or sharing a snapshot.
6
+
7
+ Bundle schema (``bundle_version: 1``):
8
+
9
+ .. code-block:: json
10
+
11
+ {
12
+ "bundle_version": 1,
13
+ "exported_at": "<ISO-8601>",
14
+ "agent_name": "opus",
15
+ "skcapstone_version": "0.9.0",
16
+ "identity": { ... },
17
+ "config": { ... },
18
+ "soul": {
19
+ "base": { ... },
20
+ "active": { ... },
21
+ "installed": { "soul-name": { ... } }
22
+ },
23
+ "memories": [ { "memory_id": ..., "content": ..., ... } ],
24
+ "conversations": { "peer-name": [ { "role": ..., "content": ..., "timestamp": ... } ] }
25
+ }
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import logging
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Any, Optional
35
+
36
+ import yaml
37
+
38
+ from . import __version__
39
+ from .memory_engine import list_memories, store as memory_store
40
+ from .models import MemoryLayer
41
+
42
+ logger = logging.getLogger("skcapstone.export")
43
+
44
+ BUNDLE_VERSION = 1
45
+
46
+ # Files relative to home that contain identity data
47
+ _IDENTITY_FILE = "identity/identity.json"
48
+ _CONFIG_FILE = "config/config.yaml"
49
+ _SOUL_BASE = "soul/base.json"
50
+ _SOUL_ACTIVE = "soul/active.json"
51
+ _SOUL_INSTALLED_DIR = "soul/installed"
52
+ _CONVERSATIONS_DIR = "conversations"
53
+
54
+
55
+ def export_bundle(home: Path) -> dict[str, Any]:
56
+ """Export the full agent state as a portable JSON-serializable bundle.
57
+
58
+ Collects identity, config, soul overlays, all memories, and all
59
+ conversation histories from the agent home directory. Missing
60
+ sections are included as empty dicts/lists rather than raising.
61
+
62
+ Args:
63
+ home: Agent home directory (e.g. ``~/.skcapstone``).
64
+
65
+ Returns:
66
+ dict: Fully serializable bundle, ready for ``json.dumps``.
67
+ """
68
+ home = Path(home).expanduser()
69
+
70
+ bundle: dict[str, Any] = {
71
+ "bundle_version": BUNDLE_VERSION,
72
+ "exported_at": datetime.now(timezone.utc).isoformat(),
73
+ "agent_name": _read_agent_name(home),
74
+ "skcapstone_version": __version__,
75
+ "identity": _load_identity(home),
76
+ "config": _load_config(home),
77
+ "soul": _load_soul(home),
78
+ "memories": _load_memories(home),
79
+ "conversations": _load_conversations(home),
80
+ }
81
+
82
+ logger.info(
83
+ "Exported bundle for %s: %d memories, %d conversations",
84
+ bundle["agent_name"],
85
+ len(bundle["memories"]),
86
+ len(bundle["conversations"]),
87
+ )
88
+ return bundle
89
+
90
+
91
+ def import_bundle(
92
+ home: Path,
93
+ bundle: dict[str, Any],
94
+ overwrite_identity: bool = False,
95
+ overwrite_config: bool = False,
96
+ overwrite_soul: bool = False,
97
+ ) -> dict[str, Any]:
98
+ """Import an agent state bundle into the target home directory.
99
+
100
+ Memories are imported using duplicate-ID detection (existing memories
101
+ are never overwritten). Conversations are merged per-peer, appending
102
+ only messages not already present. Identity, config, and soul are
103
+ written only when the corresponding flag is set or the file is absent.
104
+
105
+ Args:
106
+ home: Target agent home directory.
107
+ bundle: Bundle dict as produced by :func:`export_bundle`.
108
+ overwrite_identity: Overwrite ``identity/identity.json`` even if
109
+ the file already exists.
110
+ overwrite_config: Overwrite ``config/config.yaml`` even if it
111
+ already exists.
112
+ overwrite_soul: Overwrite soul files even if they already exist.
113
+
114
+ Returns:
115
+ dict: Import summary with keys ``memories_imported``,
116
+ ``conversations_imported``, ``identity_written``,
117
+ ``config_written``, ``soul_files_written``, ``errors``.
118
+ """
119
+ home = Path(home).expanduser()
120
+ home.mkdir(parents=True, exist_ok=True)
121
+
122
+ _validate_bundle(bundle)
123
+
124
+ errors: list[str] = []
125
+ summary: dict[str, Any] = {
126
+ "memories_imported": 0,
127
+ "conversations_imported": 0,
128
+ "identity_written": False,
129
+ "config_written": False,
130
+ "soul_files_written": 0,
131
+ "errors": errors,
132
+ }
133
+
134
+ # --- identity ---
135
+ identity_path = home / _IDENTITY_FILE
136
+ if bundle.get("identity") and (overwrite_identity or not identity_path.exists()):
137
+ try:
138
+ identity_path.parent.mkdir(parents=True, exist_ok=True)
139
+ identity_path.write_text(
140
+ json.dumps(bundle["identity"], indent=2), encoding="utf-8"
141
+ )
142
+ summary["identity_written"] = True
143
+ except OSError as exc:
144
+ errors.append(f"identity write failed: {exc}")
145
+
146
+ # --- config ---
147
+ config_path = home / _CONFIG_FILE
148
+ if bundle.get("config") and (overwrite_config or not config_path.exists()):
149
+ try:
150
+ config_path.parent.mkdir(parents=True, exist_ok=True)
151
+ config_path.write_text(
152
+ yaml.safe_dump(bundle["config"], default_flow_style=False),
153
+ encoding="utf-8",
154
+ )
155
+ summary["config_written"] = True
156
+ except OSError as exc:
157
+ errors.append(f"config write failed: {exc}")
158
+
159
+ # --- soul ---
160
+ soul_section = bundle.get("soul") or {}
161
+ soul_written = 0
162
+
163
+ base_path = home / _SOUL_BASE
164
+ if soul_section.get("base") and (overwrite_soul or not base_path.exists()):
165
+ try:
166
+ base_path.parent.mkdir(parents=True, exist_ok=True)
167
+ base_path.write_text(
168
+ json.dumps(soul_section["base"], indent=2), encoding="utf-8"
169
+ )
170
+ soul_written += 1
171
+ except OSError as exc:
172
+ errors.append(f"soul/base write failed: {exc}")
173
+
174
+ active_path = home / _SOUL_ACTIVE
175
+ if soul_section.get("active") and (overwrite_soul or not active_path.exists()):
176
+ try:
177
+ active_path.parent.mkdir(parents=True, exist_ok=True)
178
+ active_path.write_text(
179
+ json.dumps(soul_section["active"], indent=2), encoding="utf-8"
180
+ )
181
+ soul_written += 1
182
+ except OSError as exc:
183
+ errors.append(f"soul/active write failed: {exc}")
184
+
185
+ installed_dir = home / _SOUL_INSTALLED_DIR
186
+ for soul_name, soul_data in (soul_section.get("installed") or {}).items():
187
+ soul_file = installed_dir / f"{soul_name}.json"
188
+ if overwrite_soul or not soul_file.exists():
189
+ try:
190
+ soul_file.parent.mkdir(parents=True, exist_ok=True)
191
+ soul_file.write_text(json.dumps(soul_data, indent=2), encoding="utf-8")
192
+ soul_written += 1
193
+ except OSError as exc:
194
+ errors.append(f"soul/installed/{soul_name} write failed: {exc}")
195
+
196
+ summary["soul_files_written"] = soul_written
197
+
198
+ # --- memories ---
199
+ imported_memories = _import_memories(home, bundle.get("memories") or [])
200
+ summary["memories_imported"] = imported_memories
201
+ if imported_memories:
202
+ errors_from_mem: list[str] = [] # import_memories logs but doesn't surface
203
+ logger.info("Imported %d memories", imported_memories)
204
+
205
+ # --- conversations ---
206
+ imported_convs = _import_conversations(
207
+ home, bundle.get("conversations") or {}, overwrite=overwrite_soul
208
+ )
209
+ summary["conversations_imported"] = imported_convs
210
+
211
+ logger.info(
212
+ "Import complete: %d memories, %d conversations, %d soul files",
213
+ summary["memories_imported"],
214
+ summary["conversations_imported"],
215
+ summary["soul_files_written"],
216
+ )
217
+ return summary
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Private helpers — export
222
+ # ---------------------------------------------------------------------------
223
+
224
+
225
+ def _read_agent_name(home: Path) -> str:
226
+ """Read the agent name from identity.json or config.yaml."""
227
+ for rel in (_IDENTITY_FILE, _CONFIG_FILE):
228
+ p = home / rel
229
+ if p.exists():
230
+ try:
231
+ if p.suffix in (".yaml", ".yml"):
232
+ data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
233
+ else:
234
+ data = json.loads(p.read_text(encoding="utf-8"))
235
+ name = data.get("name") or data.get("agent_name")
236
+ if name:
237
+ return str(name)
238
+ except Exception:
239
+ continue
240
+ return "unknown"
241
+
242
+
243
+ def _load_identity(home: Path) -> dict:
244
+ p = home / _IDENTITY_FILE
245
+ if not p.exists():
246
+ return {}
247
+ try:
248
+ return json.loads(p.read_text(encoding="utf-8"))
249
+ except Exception as exc:
250
+ logger.warning("Cannot read identity: %s", exc)
251
+ return {}
252
+
253
+
254
+ def _load_config(home: Path) -> dict:
255
+ p = home / _CONFIG_FILE
256
+ if not p.exists():
257
+ return {}
258
+ try:
259
+ data = yaml.safe_load(p.read_text(encoding="utf-8"))
260
+ return data if isinstance(data, dict) else {}
261
+ except Exception as exc:
262
+ logger.warning("Cannot read config: %s", exc)
263
+ return {}
264
+
265
+
266
+ def _load_soul(home: Path) -> dict:
267
+ soul: dict[str, Any] = {"base": {}, "active": None, "installed": {}}
268
+
269
+ base_p = home / _SOUL_BASE
270
+ if base_p.exists():
271
+ try:
272
+ soul["base"] = json.loads(base_p.read_text(encoding="utf-8"))
273
+ except Exception as exc:
274
+ logger.warning("Cannot read soul/base: %s", exc)
275
+
276
+ active_p = home / _SOUL_ACTIVE
277
+ if active_p.exists():
278
+ try:
279
+ soul["active"] = json.loads(active_p.read_text(encoding="utf-8"))
280
+ except Exception as exc:
281
+ logger.warning("Cannot read soul/active: %s", exc)
282
+
283
+ installed_dir = home / _SOUL_INSTALLED_DIR
284
+ if installed_dir.is_dir():
285
+ for f in sorted(installed_dir.glob("*.json")):
286
+ try:
287
+ soul["installed"][f.stem] = json.loads(f.read_text(encoding="utf-8"))
288
+ except Exception as exc:
289
+ logger.warning("Cannot read soul/installed/%s: %s", f.name, exc)
290
+
291
+ return soul
292
+
293
+
294
+ def _load_memories(home: Path) -> list[dict]:
295
+ """Load all memories from all layers."""
296
+ entries = list_memories(home, limit=10000)
297
+ result = []
298
+ for e in entries:
299
+ d = e.model_dump(mode="json")
300
+ # Ensure datetimes are ISO strings
301
+ for key in ("created_at", "accessed_at"):
302
+ val = d.get(key)
303
+ if val is not None and hasattr(val, "isoformat"):
304
+ d[key] = val.isoformat()
305
+ result.append(d)
306
+ return result
307
+
308
+
309
+ def _load_conversations(home: Path) -> dict[str, list[dict]]:
310
+ """Load all conversation histories from conversations/ dir."""
311
+ conv_dir = home / _CONVERSATIONS_DIR
312
+ if not conv_dir.is_dir():
313
+ return {}
314
+
315
+ conversations: dict[str, list[dict]] = {}
316
+ for f in sorted(conv_dir.glob("*.json")):
317
+ peer = f.stem
318
+ try:
319
+ data = json.loads(f.read_text(encoding="utf-8"))
320
+ if isinstance(data, list):
321
+ conversations[peer] = data
322
+ elif isinstance(data, dict) and "messages" in data:
323
+ conversations[peer] = data["messages"]
324
+ else:
325
+ conversations[peer] = []
326
+ except Exception as exc:
327
+ logger.warning("Cannot read conversation %s: %s", f.name, exc)
328
+
329
+ return conversations
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # Private helpers — import
334
+ # ---------------------------------------------------------------------------
335
+
336
+
337
+ def _validate_bundle(bundle: dict[str, Any]) -> None:
338
+ """Raise ValueError if the bundle is structurally invalid."""
339
+ if not isinstance(bundle, dict):
340
+ raise ValueError("Bundle must be a JSON object")
341
+ version = bundle.get("bundle_version")
342
+ if version is None:
343
+ raise ValueError("Missing bundle_version field")
344
+ if version != BUNDLE_VERSION:
345
+ raise ValueError(
346
+ f"Unsupported bundle_version {version!r} (expected {BUNDLE_VERSION})"
347
+ )
348
+
349
+
350
+ def _import_memories(home: Path, memory_list: list[dict]) -> int:
351
+ """Import memories from a bundle, preserving original IDs for idempotency.
352
+
353
+ Writes memory JSON files directly (bypassing store()) so the original
354
+ memory_id is preserved. This makes re-importing the same bundle a no-op.
355
+ """
356
+ if not memory_list:
357
+ return 0
358
+
359
+ from .models import MemoryEntry
360
+ from .memory_engine import _memory_dir, _update_index
361
+
362
+ mem_dir = _memory_dir(home) # creates layer subdirs
363
+
364
+ # Build set of existing memory IDs from disk
365
+ existing: set[str] = set()
366
+ for layer in MemoryLayer:
367
+ layer_dir = mem_dir / layer.value
368
+ if layer_dir.is_dir():
369
+ for f in layer_dir.glob("*.json"):
370
+ existing.add(f.stem)
371
+
372
+ imported = 0
373
+ for mem_data in memory_list:
374
+ mid = mem_data.get("memory_id", "")
375
+ if not mid or mid in existing:
376
+ continue
377
+ try:
378
+ layer_raw = mem_data.get("layer", "short-term")
379
+ layer = MemoryLayer(layer_raw) if isinstance(layer_raw, str) else MemoryLayer.SHORT_TERM
380
+ entry = MemoryEntry(
381
+ memory_id=mid,
382
+ content=mem_data["content"],
383
+ tags=mem_data.get("tags") or [],
384
+ source=mem_data.get("source", "bundle-import"),
385
+ importance=float(mem_data.get("importance", 0.5)),
386
+ layer=layer,
387
+ metadata=mem_data.get("metadata") or {},
388
+ soul_context=mem_data.get("soul_context"),
389
+ )
390
+ # Write with the original memory_id so re-import is idempotent
391
+ path = mem_dir / layer.value / f"{mid}.json"
392
+ path.parent.mkdir(parents=True, exist_ok=True)
393
+ path.write_text(entry.model_dump_json(indent=2), encoding="utf-8")
394
+ _update_index(home, entry)
395
+ existing.add(mid)
396
+ imported += 1
397
+ except (KeyError, ValueError) as exc:
398
+ logger.warning("Skipping invalid memory in bundle: %s", exc)
399
+
400
+ return imported
401
+
402
+
403
+ def _import_conversations(
404
+ home: Path, conversations: dict[str, list[dict]], overwrite: bool = False
405
+ ) -> int:
406
+ """Import conversation histories, merging per peer."""
407
+ if not conversations:
408
+ return 0
409
+
410
+ conv_dir = home / _CONVERSATIONS_DIR
411
+ conv_dir.mkdir(parents=True, exist_ok=True)
412
+
413
+ imported = 0
414
+ for peer, messages in conversations.items():
415
+ if not peer or not isinstance(messages, list):
416
+ continue
417
+
418
+ peer_file = conv_dir / f"{peer}.json"
419
+ existing_messages: list[dict] = []
420
+
421
+ if peer_file.exists() and not overwrite:
422
+ try:
423
+ existing_data = json.loads(peer_file.read_text(encoding="utf-8"))
424
+ if isinstance(existing_data, list):
425
+ existing_messages = existing_data
426
+ except Exception:
427
+ pass
428
+
429
+ # Deduplicate by (role, content, timestamp) tuple
430
+ existing_keys = {
431
+ (m.get("role"), m.get("content"), m.get("timestamp"))
432
+ for m in existing_messages
433
+ }
434
+ new_messages = [
435
+ m for m in messages
436
+ if (m.get("role"), m.get("content"), m.get("timestamp")) not in existing_keys
437
+ ]
438
+
439
+ merged = existing_messages + new_messages
440
+ if new_messages or overwrite:
441
+ try:
442
+ peer_file.write_text(json.dumps(merged, indent=2), encoding="utf-8")
443
+ imported += len(new_messages)
444
+ except OSError as exc:
445
+ logger.warning("Cannot write conversation %s: %s", peer, exc)
446
+
447
+ return imported
@@ -0,0 +1,186 @@
1
+ """Fallback event tracker — graceful degradation logging.
2
+
3
+ Records every LLM fallback event to ~/.skcapstone/fallbacks.json so
4
+ operators can diagnose which backends are failing and how often the
5
+ agent is degrading to lower-quality providers.
6
+
7
+ Architecture:
8
+ FallbackEvent — Pydantic model for a single fallback occurrence
9
+ FallbackTracker — thread-safe writer / reader for fallbacks.json
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import threading
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from pydantic import BaseModel, Field
22
+
23
+ from . import AGENT_HOME
24
+
25
+ logger = logging.getLogger("skcapstone.fallback_tracker")
26
+
27
+ _DEFAULT_PATH = Path(AGENT_HOME).expanduser() / "fallbacks.json"
28
+ _MAX_EVENTS = 1000 # cap file size; rotate oldest when exceeded
29
+
30
+
31
+ class FallbackEvent(BaseModel):
32
+ """A single LLM fallback occurrence.
33
+
34
+ Attributes:
35
+ timestamp: ISO-8601 UTC timestamp of the event.
36
+ primary_model: The model that was originally selected.
37
+ primary_backend: The backend provider of the primary model.
38
+ fallback_model: The model actually used (or ``"none"`` if all failed).
39
+ fallback_backend: The backend that served the response.
40
+ reason: Short human-readable description of why the fallback occurred.
41
+ success: Whether the fallback itself produced a usable response.
42
+ """
43
+
44
+ timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
45
+ primary_model: str
46
+ primary_backend: str
47
+ fallback_model: str
48
+ fallback_backend: str
49
+ reason: str
50
+ success: bool
51
+
52
+
53
+ class FallbackTracker:
54
+ """Thread-safe store for fallback events.
55
+
56
+ Events are appended to a JSON file (list of objects). The file is
57
+ created on first write. Reads never raise — a missing or corrupt
58
+ file returns an empty list.
59
+
60
+ Args:
61
+ path: Path to the fallbacks JSON file.
62
+ Defaults to ``~/.skcapstone/fallbacks.json``.
63
+ max_events: Maximum number of events retained (oldest are pruned).
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ path: Optional[Path] = None,
69
+ max_events: int = _MAX_EVENTS,
70
+ ) -> None:
71
+ self._path = Path(path) if path is not None else _DEFAULT_PATH
72
+ self._max_events = max_events
73
+ self._lock = threading.Lock()
74
+
75
+ # ------------------------------------------------------------------
76
+ # Public API
77
+ # ------------------------------------------------------------------
78
+
79
+ def record(self, event: FallbackEvent) -> None:
80
+ """Append *event* to the fallback log.
81
+
82
+ Args:
83
+ event: The fallback event to persist.
84
+ """
85
+ with self._lock:
86
+ events = self._load_raw()
87
+ events.append(event.model_dump())
88
+ if len(events) > self._max_events:
89
+ events = events[-self._max_events :]
90
+ self._save_raw(events)
91
+ logger.debug(
92
+ "Fallback recorded: %s → %s (%s, success=%s)",
93
+ event.primary_backend,
94
+ event.fallback_backend,
95
+ event.reason,
96
+ event.success,
97
+ )
98
+
99
+ def load_events(self, limit: int = 0) -> list[FallbackEvent]:
100
+ """Return stored fallback events, newest first.
101
+
102
+ Args:
103
+ limit: If > 0, return only the *limit* most recent events.
104
+
105
+ Returns:
106
+ List of :class:`FallbackEvent` objects.
107
+ """
108
+ with self._lock:
109
+ raw = self._load_raw()
110
+
111
+ events: list[FallbackEvent] = []
112
+ for item in reversed(raw):
113
+ try:
114
+ events.append(FallbackEvent(**item))
115
+ except Exception: # noqa: BLE001
116
+ continue # skip corrupt entries
117
+
118
+ if limit > 0:
119
+ return events[:limit]
120
+ return events
121
+
122
+ def clear(self) -> int:
123
+ """Delete all stored fallback events.
124
+
125
+ Returns:
126
+ Number of events that were cleared.
127
+ """
128
+ with self._lock:
129
+ raw = self._load_raw()
130
+ count = len(raw)
131
+ self._save_raw([])
132
+ return count
133
+
134
+ @property
135
+ def path(self) -> Path:
136
+ """Path to the fallbacks JSON file."""
137
+ return self._path
138
+
139
+ # ------------------------------------------------------------------
140
+ # Internal helpers
141
+ # ------------------------------------------------------------------
142
+
143
+ def _load_raw(self) -> list[dict]:
144
+ """Load raw JSON list from disk without locking."""
145
+ if not self._path.exists():
146
+ return []
147
+ try:
148
+ text = self._path.read_text(encoding="utf-8")
149
+ data = json.loads(text)
150
+ if isinstance(data, list):
151
+ return data
152
+ except (json.JSONDecodeError, OSError):
153
+ logger.warning("fallbacks.json is corrupt or unreadable — resetting")
154
+ return []
155
+
156
+ def _save_raw(self, events: list[dict]) -> None:
157
+ """Write raw JSON list to disk without locking."""
158
+ self._path.parent.mkdir(parents=True, exist_ok=True)
159
+ self._path.write_text(
160
+ json.dumps(events, indent=2, ensure_ascii=False),
161
+ encoding="utf-8",
162
+ )
163
+
164
+
165
+ # Module-level singleton — shared across the process
166
+ _tracker: Optional[FallbackTracker] = None
167
+ _tracker_lock = threading.Lock()
168
+
169
+
170
+ def get_tracker(path: Optional[Path] = None) -> FallbackTracker:
171
+ """Return the module-level :class:`FallbackTracker` singleton.
172
+
173
+ Creates it on first call. Passing *path* on the first call
174
+ customises the storage location.
175
+
176
+ Args:
177
+ path: Optional override for the fallbacks file path.
178
+
179
+ Returns:
180
+ The singleton :class:`FallbackTracker`.
181
+ """
182
+ global _tracker
183
+ with _tracker_lock:
184
+ if _tracker is None:
185
+ _tracker = FallbackTracker(path=path)
186
+ return _tracker