@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,745 @@
1
+ """
2
+ Syncthing seed auto-importer.
3
+
4
+ Watches ~/.skcapstone/sync/inbox/ for new .seed.json files arriving
5
+ via Syncthing and auto-imports them into the SQLite memory backend.
6
+
7
+ Uses watchdog.observers.Observer (same pattern as consciousness_loop.py)
8
+ with a polling fallback for environments without inotify support.
9
+
10
+ Tracks processed files in ~/.skcapstone/sync/processed.json to avoid
11
+ re-importing seeds across restarts.
12
+
13
+ Architecture:
14
+ SeedFileHandler -- watchdog event handler with debounce
15
+ SyncWatcher -- orchestrator: watch + poll + import
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import logging
23
+ import threading
24
+ import time
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Any, Optional
28
+
29
+ logger = logging.getLogger("skcapstone.sync_watcher")
30
+
31
+ SEED_EXTENSION = ".seed.json"
32
+ DEFAULT_INBOX = "~/.skcapstone/sync/inbox"
33
+ DEFAULT_PROCESSED_LOG = "~/.skcapstone/sync/processed.json"
34
+ DEBOUNCE_MS = 500
35
+ POLL_INTERVAL_S = 30
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Configuration
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def load_sync_config(home: Path) -> dict[str, Any]:
44
+ """Load sync watcher configuration from config.yaml.
45
+
46
+ Reads the ``sync`` section and applies defaults for any missing keys.
47
+
48
+ Args:
49
+ home: Agent home directory (~/.skcapstone).
50
+
51
+ Returns:
52
+ Dict with auto_import, inbox_path, and processed_log.
53
+ """
54
+ defaults = {
55
+ "auto_import": True,
56
+ "auto_vector_index": True,
57
+ "auto_graph_index": True,
58
+ "inbox_path": str(home / "sync" / "inbox"),
59
+ "processed_log": str(home / "sync" / "processed.json"),
60
+ }
61
+
62
+ config_file = home / "config" / "config.yaml"
63
+ if not config_file.exists():
64
+ return defaults
65
+
66
+ try:
67
+ import yaml as _yaml
68
+
69
+ data = _yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
70
+ sync_data = data.get("sync", {})
71
+ return {
72
+ "auto_import": sync_data.get("auto_import", defaults["auto_import"]),
73
+ "auto_vector_index": sync_data.get(
74
+ "auto_vector_index", defaults["auto_vector_index"]
75
+ ),
76
+ "auto_graph_index": sync_data.get(
77
+ "auto_graph_index", defaults["auto_graph_index"]
78
+ ),
79
+ "inbox_path": str(
80
+ Path(sync_data.get("inbox_path", defaults["inbox_path"])).expanduser()
81
+ ),
82
+ "processed_log": str(
83
+ Path(
84
+ sync_data.get("processed_log", defaults["processed_log"])
85
+ ).expanduser()
86
+ ),
87
+ }
88
+ except Exception as exc:
89
+ logger.debug("Could not load sync config: %s — using defaults", exc)
90
+ return defaults
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Processed file tracker
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ class ProcessedTracker:
99
+ """Tracks which seed files have already been imported.
100
+
101
+ Persists a JSON file mapping filename -> import timestamp so that
102
+ seeds are not re-imported after daemon restart.
103
+
104
+ Args:
105
+ log_path: Path to the processed.json file.
106
+ """
107
+
108
+ def __init__(self, log_path: str | Path) -> None:
109
+ self._path = Path(log_path)
110
+ self._entries: dict[str, str] = {}
111
+ self._lock = threading.Lock()
112
+ self._load()
113
+
114
+ def _load(self) -> None:
115
+ """Load existing entries from disk."""
116
+ if self._path.exists():
117
+ try:
118
+ data = json.loads(self._path.read_text(encoding="utf-8"))
119
+ if isinstance(data, dict):
120
+ self._entries = data
121
+ except (json.JSONDecodeError, OSError) as exc:
122
+ logger.warning("Could not load processed log: %s", exc)
123
+
124
+ def is_processed(self, filename: str) -> bool:
125
+ """Check if a seed file has already been imported.
126
+
127
+ Args:
128
+ filename: The seed filename (basename).
129
+
130
+ Returns:
131
+ True if already processed.
132
+ """
133
+ with self._lock:
134
+ return filename in self._entries
135
+
136
+ def mark_processed(self, filename: str) -> None:
137
+ """Record a file as processed and persist to disk.
138
+
139
+ Args:
140
+ filename: The seed filename (basename).
141
+ """
142
+ with self._lock:
143
+ self._entries[filename] = datetime.now(timezone.utc).isoformat()
144
+ self._persist()
145
+
146
+ def _persist(self) -> None:
147
+ """Write current entries to disk."""
148
+ try:
149
+ self._path.parent.mkdir(parents=True, exist_ok=True)
150
+ self._path.write_text(
151
+ json.dumps(self._entries, indent=2, sort_keys=True),
152
+ encoding="utf-8",
153
+ )
154
+ except OSError as exc:
155
+ logger.error("Could not persist processed log: %s", exc)
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Seed importer
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ def _compute_seed_hash(data: dict) -> str:
164
+ """Compute a stable hash for deduplication.
165
+
166
+ Args:
167
+ data: Parsed seed dictionary.
168
+
169
+ Returns:
170
+ SHA-256 hex digest of the canonical JSON.
171
+ """
172
+ canonical = json.dumps(data, sort_keys=True, default=str)
173
+ return hashlib.sha256(canonical.encode()).hexdigest()[:16]
174
+
175
+
176
+ def vector_index_seed(memory: "Memory") -> bool:
177
+ """Index a memory in SKVector (Qdrant) if available.
178
+
179
+ Attempts to connect to the vector backend using skmemory's
180
+ configuration resolution (CLI > env > config file). If the
181
+ vector backend is unreachable or dependencies are missing,
182
+ logs a debug message and returns False without raising.
183
+
184
+ Args:
185
+ memory: The Memory object to index (already saved to SQLite).
186
+
187
+ Returns:
188
+ True if the memory was successfully indexed, False otherwise.
189
+ """
190
+ try:
191
+ from skmemory.config import merge_env_and_config
192
+ from skmemory.backends.skvector_backend import SKVectorBackend
193
+ except ImportError:
194
+ logger.debug(
195
+ "skmemory vector backend not importable — skipping vector index"
196
+ )
197
+ return False
198
+
199
+ try:
200
+ skvector_url, skvector_key, _ = merge_env_and_config()
201
+ if not skvector_url:
202
+ logger.debug(
203
+ "No SKVector URL configured — skipping vector index"
204
+ )
205
+ return False
206
+
207
+ backend = SKVectorBackend(url=skvector_url, api_key=skvector_key)
208
+ backend.save(memory)
209
+ logger.debug("Indexed memory %s in SKVector", memory.id)
210
+ return True
211
+ except Exception as exc:
212
+ logger.debug(
213
+ "SKVector indexing failed for memory %s: %s — continuing without vector index",
214
+ memory.id,
215
+ exc,
216
+ )
217
+ return False
218
+
219
+
220
+ def graph_index_seed(memory: "Memory") -> bool:
221
+ """Index a memory in SKGraph (FalkorDB) if available.
222
+
223
+ Attempts to connect to the graph backend using skmemory's
224
+ configuration resolution (CLI > env > config file). If the
225
+ graph backend is unreachable or dependencies are missing,
226
+ logs a debug message and returns False without raising.
227
+
228
+ Args:
229
+ memory: The Memory object to index (already saved to SQLite).
230
+
231
+ Returns:
232
+ True if the memory was successfully indexed, False otherwise.
233
+ """
234
+ try:
235
+ from skmemory.config import merge_env_and_config
236
+ from skmemory.backends.skgraph_backend import SKGraphBackend
237
+ except ImportError:
238
+ logger.debug(
239
+ "skmemory graph backend not importable — skipping graph index"
240
+ )
241
+ return False
242
+
243
+ try:
244
+ _, _, skgraph_url = merge_env_and_config()
245
+ if not skgraph_url:
246
+ logger.debug(
247
+ "No SKGraph URL configured — skipping graph index"
248
+ )
249
+ return False
250
+
251
+ backend = SKGraphBackend(url=skgraph_url)
252
+ result = backend.index_memory(memory)
253
+ if result:
254
+ logger.debug("Indexed memory %s in SKGraph", memory.id)
255
+ return result
256
+ except Exception as exc:
257
+ logger.debug(
258
+ "SKGraph indexing failed for memory %s: %s — continuing without graph index",
259
+ memory.id,
260
+ exc,
261
+ )
262
+ return False
263
+
264
+
265
+ def import_seed_to_memory(seed_path: Path, home: Path) -> Optional[str]:
266
+ """Parse a .seed.json file and store its contents via skmemory.
267
+
268
+ Extracts memory_entries from the seed (if present) and stores each
269
+ one via the MemoryStore.snapshot() API. Also imports identity and
270
+ trust data via the existing pull_seeds infrastructure.
271
+
272
+ Args:
273
+ seed_path: Path to the .seed.json file.
274
+ home: Agent home directory.
275
+
276
+ Returns:
277
+ Summary string of what was imported, or None on failure.
278
+ """
279
+ try:
280
+ raw = seed_path.read_text(encoding="utf-8")
281
+ data = json.loads(raw)
282
+ except (json.JSONDecodeError, OSError) as exc:
283
+ logger.error("Failed to read seed %s: %s", seed_path.name, exc)
284
+ return None
285
+
286
+ agent_name = data.get("agent_name", "unknown")
287
+ source_host = data.get("source_host", "unknown")
288
+ created_at = data.get("created_at", "")
289
+ seed_hash = _compute_seed_hash(data)
290
+ imported_count = 0
291
+ results: list[str] = []
292
+
293
+ # Load sync config to check auto_vector_index and auto_graph_index flags
294
+ sync_config = load_sync_config(home)
295
+ auto_vector_index = sync_config.get("auto_vector_index", True)
296
+ auto_graph_index = sync_config.get("auto_graph_index", True)
297
+
298
+ # Import memory entries via skmemory API
299
+ memory_entries = data.get("memory_entries", [])
300
+ vector_indexed = 0
301
+ graph_indexed = 0
302
+ if memory_entries:
303
+ try:
304
+ from skmemory.store import MemoryStore
305
+ from skmemory.models import MemoryLayer
306
+
307
+ store = MemoryStore(use_sqlite=True)
308
+
309
+ for entry in memory_entries:
310
+ title = entry.get("title", "Synced memory")
311
+ content = entry.get("content", "")
312
+ layer_str = entry.get("layer", "short-term")
313
+ tags = entry.get("tags", [])
314
+ source_ref = entry.get("source_ref", f"sync:{agent_name}@{source_host}")
315
+
316
+ # Map layer string to enum
317
+ layer_map = {
318
+ "short-term": MemoryLayer.SHORT,
319
+ "mid-term": MemoryLayer.MID,
320
+ "long-term": MemoryLayer.LONG,
321
+ }
322
+ layer = layer_map.get(layer_str, MemoryLayer.SHORT)
323
+
324
+ # Add sync provenance tags
325
+ sync_tags = list(tags) + [
326
+ "sync:imported",
327
+ f"sync:from:{agent_name}",
328
+ f"sync:host:{source_host}",
329
+ f"sync:hash:{seed_hash}",
330
+ ]
331
+
332
+ memory = store.snapshot(
333
+ title=title,
334
+ content=content,
335
+ layer=layer,
336
+ tags=sync_tags,
337
+ source="syncthing",
338
+ source_ref=source_ref,
339
+ metadata={
340
+ "sync_source_agent": agent_name,
341
+ "sync_source_host": source_host,
342
+ "sync_seed_created": created_at,
343
+ "sync_seed_hash": seed_hash,
344
+ },
345
+ )
346
+ imported_count += 1
347
+
348
+ # Auto-index in SKVector if enabled and available
349
+ if auto_vector_index and vector_index_seed(memory):
350
+ vector_indexed += 1
351
+
352
+ # Auto-index in SKGraph if enabled and available
353
+ if auto_graph_index and graph_index_seed(memory):
354
+ graph_indexed += 1
355
+
356
+ results.append(f"{imported_count} memories")
357
+ if vector_indexed:
358
+ results.append(f"{vector_indexed} vector-indexed")
359
+ if graph_indexed:
360
+ results.append(f"{graph_indexed} graph-indexed")
361
+ except ImportError:
362
+ logger.warning("skmemory not available — skipping memory import")
363
+ except Exception as exc:
364
+ logger.error("Memory import failed for %s: %s", seed_path.name, exc)
365
+
366
+ # Also use the existing pull_seeds machinery for identity/trust/FEBs
367
+ try:
368
+ if "identity" in data or "trust" in data or "febs" in data:
369
+ from .pillars.sync import pull_seeds as _pull_existing
370
+
371
+ # pull_seeds reads from inbox, but the file is already there —
372
+ # we just log what it would pick up
373
+ if "identity" in data:
374
+ results.append("identity")
375
+ if "trust" in data:
376
+ results.append("trust")
377
+ if "febs" in data:
378
+ results.append(f"{len(data.get('febs', []))} FEBs")
379
+ except Exception as exc:
380
+ logger.debug("Extended seed import skipped: %s", exc)
381
+
382
+ if results:
383
+ summary = f"Imported seed from {agent_name}@{source_host}: {', '.join(results)}"
384
+ logger.info(summary)
385
+ return summary
386
+
387
+ logger.info(
388
+ "Seed %s from %s@%s contained no importable data",
389
+ seed_path.name, agent_name, source_host,
390
+ )
391
+ return f"Seed from {agent_name}@{source_host}: no importable data"
392
+
393
+
394
+ def _log_to_short_term_memory(message: str, home: Path) -> None:
395
+ """Log an import event to the agent's short-term memory.
396
+
397
+ Args:
398
+ message: Description of what was imported.
399
+ home: Agent home directory.
400
+ """
401
+ try:
402
+ from skmemory.store import MemoryStore
403
+
404
+ store = MemoryStore(use_sqlite=True)
405
+ store.snapshot(
406
+ title="Sync import event",
407
+ content=message,
408
+ tags=["sync:event", "sync:import-log"],
409
+ source="sync_watcher",
410
+ source_ref="auto",
411
+ )
412
+ except Exception as exc:
413
+ logger.debug("Could not log import to memory: %s", exc)
414
+
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # Watchdog event handler
418
+ # ---------------------------------------------------------------------------
419
+
420
+
421
+ class SeedFileHandler:
422
+ """Handles file creation events for .seed.json files.
423
+
424
+ Implements debouncing to handle Syncthing's multi-stage file writes.
425
+
426
+ Args:
427
+ callback: Function to call with each new seed file path.
428
+ debounce_ms: Minimum milliseconds between events for the same file.
429
+ """
430
+
431
+ def __init__(self, callback, debounce_ms: int = DEBOUNCE_MS) -> None:
432
+ self._callback = callback
433
+ self._debounce_ms = debounce_ms
434
+ self._last_event: dict[str, float] = {}
435
+
436
+ def on_created(self, event) -> None:
437
+ """Handle file creation events.
438
+
439
+ Args:
440
+ event: Watchdog FileCreatedEvent (or similar with src_path).
441
+ """
442
+ if hasattr(event, "is_directory") and event.is_directory:
443
+ return
444
+
445
+ src_path = event.src_path if hasattr(event, "src_path") else str(event)
446
+ if not src_path.endswith(SEED_EXTENSION):
447
+ return
448
+
449
+ # Debounce: Syncthing writes in stages
450
+ now = time.monotonic()
451
+ last = self._last_event.get(src_path, 0)
452
+ if (now - last) * 1000 < self._debounce_ms:
453
+ return
454
+ self._last_event[src_path] = now
455
+
456
+ # Clean up old entries (prevent unbounded growth)
457
+ if len(self._last_event) > 100:
458
+ cutoff = now - 60
459
+ self._last_event = {
460
+ k: v for k, v in self._last_event.items() if v > cutoff
461
+ }
462
+
463
+ logger.debug("Seed file detected: %s", src_path)
464
+ self._callback(Path(src_path))
465
+
466
+ def on_modified(self, event) -> None:
467
+ """Handle file modification events (Syncthing rewrites).
468
+
469
+ Args:
470
+ event: Watchdog FileModifiedEvent.
471
+ """
472
+ # Treat modifications the same as creation for Syncthing compatibility
473
+ self.on_created(event)
474
+
475
+
476
+ class _WatchdogSyncAdapter:
477
+ """Adapter from watchdog events to SeedFileHandler callback."""
478
+
479
+ def __init__(self, callback) -> None:
480
+ self._handler = SeedFileHandler(callback)
481
+
482
+ def dispatch(self, event) -> None:
483
+ """Dispatch a watchdog event.
484
+
485
+ Args:
486
+ event: Watchdog event object.
487
+ """
488
+ etype = getattr(event, "event_type", "")
489
+ if etype in ("created", "modified"):
490
+ self._handler.on_created(event)
491
+
492
+
493
+ # ---------------------------------------------------------------------------
494
+ # SyncWatcher orchestrator
495
+ # ---------------------------------------------------------------------------
496
+
497
+
498
+ class SyncWatcher:
499
+ """Watches the sync inbox for new .seed.json files and auto-imports them.
500
+
501
+ Combines watchdog inotify monitoring (for sub-second response) with
502
+ a periodic polling fallback (for reliability). Tracks already-processed
503
+ files to avoid duplicate imports.
504
+
505
+ Args:
506
+ home: Agent home directory (~/.skcapstone).
507
+ stop_event: Threading event to signal shutdown.
508
+ inbox_path: Override for the inbox directory path.
509
+ processed_log: Override for the processed.json path.
510
+ """
511
+
512
+ def __init__(
513
+ self,
514
+ home: Path,
515
+ stop_event: threading.Event,
516
+ inbox_path: Optional[str] = None,
517
+ processed_log: Optional[str] = None,
518
+ ) -> None:
519
+ self._home = home
520
+ self._stop_event = stop_event
521
+
522
+ config = load_sync_config(home)
523
+ if not config.get("auto_import", True):
524
+ self._enabled = False
525
+ logger.info("Sync auto-import disabled by config")
526
+ else:
527
+ self._enabled = True
528
+
529
+ self._inbox = Path(inbox_path or config["inbox_path"]).expanduser()
530
+ self._tracker = ProcessedTracker(
531
+ processed_log or config["processed_log"]
532
+ )
533
+ self._observer = None
534
+ self._lock = threading.Lock()
535
+
536
+ @property
537
+ def enabled(self) -> bool:
538
+ """Whether auto-import is enabled."""
539
+ return self._enabled
540
+
541
+ def start(self) -> list[threading.Thread]:
542
+ """Start the watcher and poller threads.
543
+
544
+ Returns:
545
+ List of started daemon threads.
546
+ """
547
+ if not self._enabled:
548
+ return []
549
+
550
+ self._inbox.mkdir(parents=True, exist_ok=True)
551
+ threads: list[threading.Thread] = []
552
+
553
+ # Watchdog inotify thread
554
+ t_watch = threading.Thread(
555
+ target=self._run_watcher,
556
+ name="sync-watcher-inotify",
557
+ daemon=True,
558
+ )
559
+ t_watch.start()
560
+ threads.append(t_watch)
561
+
562
+ # Initial scan on startup
563
+ self._poll_inbox()
564
+
565
+ logger.info(
566
+ "SyncWatcher started — inbox=%s, inotify=yes, poll=%ds",
567
+ self._inbox, POLL_INTERVAL_S,
568
+ )
569
+ return threads
570
+
571
+ def stop(self) -> None:
572
+ """Stop the watcher gracefully."""
573
+ if self._observer:
574
+ try:
575
+ self._observer.stop()
576
+ self._observer.join(timeout=5)
577
+ except Exception:
578
+ pass
579
+ self._observer = None
580
+ logger.info("SyncWatcher stopped.")
581
+
582
+ def poll_inbox(self) -> int:
583
+ """Public entry point for scheduled polling.
584
+
585
+ Returns:
586
+ Number of seeds imported during this poll.
587
+ """
588
+ return self._poll_inbox()
589
+
590
+ def status(self) -> dict[str, Any]:
591
+ """Return current watcher status.
592
+
593
+ Returns:
594
+ Dict with enabled, inbox_path, observer_alive, and processed count.
595
+ """
596
+ return {
597
+ "enabled": self._enabled,
598
+ "inbox_path": str(self._inbox),
599
+ "observer_alive": (
600
+ self._observer is not None
601
+ and hasattr(self._observer, "is_alive")
602
+ and self._observer.is_alive()
603
+ ),
604
+ "processed_count": len(self._tracker._entries),
605
+ }
606
+
607
+ # ------------------------------------------------------------------
608
+ # Internal
609
+ # ------------------------------------------------------------------
610
+
611
+ def _run_watcher(self) -> None:
612
+ """Run the watchdog inotify loop with polling fallback."""
613
+ try:
614
+ from watchdog.observers import Observer
615
+
616
+ adapter = _WatchdogSyncAdapter(self._on_seed_file)
617
+ self._observer = Observer()
618
+ self._observer.schedule(adapter, str(self._inbox), recursive=False)
619
+ self._observer.start()
620
+ logger.info("Sync inotify watcher active on %s", self._inbox)
621
+
622
+ # Block until stop, polling periodically as backup
623
+ while not self._stop_event.is_set():
624
+ self._stop_event.wait(timeout=POLL_INTERVAL_S)
625
+ if not self._stop_event.is_set():
626
+ self._poll_inbox()
627
+
628
+ except ImportError:
629
+ logger.warning(
630
+ "watchdog not installed — falling back to polling only. "
631
+ "Install with: pip install watchdog"
632
+ )
633
+ # Pure polling fallback
634
+ while not self._stop_event.is_set():
635
+ self._poll_inbox()
636
+ self._stop_event.wait(timeout=POLL_INTERVAL_S)
637
+
638
+ except Exception as exc:
639
+ logger.error("Sync watcher error: %s", exc)
640
+
641
+ def _on_seed_file(self, path: Path) -> None:
642
+ """Handle a detected seed file from inotify.
643
+
644
+ Waits briefly for the file to be fully written, then imports.
645
+
646
+ Args:
647
+ path: Path to the detected .seed.json file.
648
+ """
649
+ # Brief delay to let Syncthing finish writing
650
+ time.sleep(0.5)
651
+ self._import_seed(path)
652
+
653
+ def _poll_inbox(self) -> int:
654
+ """Scan the inbox directory for unprocessed seed files.
655
+
656
+ Returns:
657
+ Number of seeds imported.
658
+ """
659
+ if not self._inbox.exists():
660
+ return 0
661
+
662
+ imported = 0
663
+ try:
664
+ for f in sorted(self._inbox.iterdir()):
665
+ if f.name.startswith("."):
666
+ continue
667
+ if not f.name.endswith(SEED_EXTENSION):
668
+ continue
669
+ if self._tracker.is_processed(f.name):
670
+ continue
671
+ if self._import_seed(f):
672
+ imported += 1
673
+ except OSError as exc:
674
+ logger.error("Inbox scan failed: %s", exc)
675
+
676
+ return imported
677
+
678
+ def _import_seed(self, seed_path: Path) -> bool:
679
+ """Import a single seed file.
680
+
681
+ Thread-safe: uses a lock to prevent concurrent imports of the
682
+ same file from inotify and polling.
683
+
684
+ Args:
685
+ seed_path: Path to the .seed.json file.
686
+
687
+ Returns:
688
+ True if import succeeded, False otherwise.
689
+ """
690
+ with self._lock:
691
+ if self._tracker.is_processed(seed_path.name):
692
+ return False
693
+
694
+ if not seed_path.exists():
695
+ return False
696
+
697
+ logger.info("Importing seed: %s", seed_path.name)
698
+
699
+ result = import_seed_to_memory(seed_path, self._home)
700
+ if result:
701
+ self._tracker.mark_processed(seed_path.name)
702
+
703
+ # Log the import event to short-term memory
704
+ _log_to_short_term_memory(result, self._home)
705
+
706
+ # Move to archive
707
+ archive = self._inbox.parent / "archive"
708
+ archive.mkdir(exist_ok=True)
709
+ try:
710
+ seed_path.rename(archive / seed_path.name)
711
+ logger.debug("Archived: %s", seed_path.name)
712
+ except OSError as exc:
713
+ logger.warning("Could not archive %s: %s", seed_path.name, exc)
714
+
715
+ return True
716
+
717
+ logger.warning("Seed import returned no result: %s", seed_path.name)
718
+ return False
719
+
720
+
721
+ # ---------------------------------------------------------------------------
722
+ # Scheduled task factory
723
+ # ---------------------------------------------------------------------------
724
+
725
+
726
+ def make_sync_inbox_scan_task(
727
+ watcher: Optional[SyncWatcher],
728
+ ) -> callable:
729
+ """Return a callback for the task scheduler to poll the sync inbox.
730
+
731
+ Args:
732
+ watcher: SyncWatcher instance (or None if disabled).
733
+
734
+ Returns:
735
+ Zero-argument callable suitable for TaskScheduler.register().
736
+ """
737
+
738
+ def _run() -> None:
739
+ if watcher is None:
740
+ return
741
+ count = watcher.poll_inbox()
742
+ if count:
743
+ logger.info("Scheduled sync scan imported %d seed(s)", count)
744
+
745
+ return _run